vaanskii
vaanskii

Reputation: 11

Managing Google OAuth2 Refresh Tokens in a Go Gin Application

I'm developing a web application in Go using the Gin framework that requires Google OAuth2 authentication and Google Drive integration. My current implementation works but has issues with refresh tokens, token expiration, and frequent re-authentication prompts.

Here's a summary of my setup:

OAuth2 Setup: I'm using markbates/goth and gothic to handle Google OAuth2 authentication.

Token Management: I save access tokens and refresh tokens to a token.json file.

Token Refresh Logic: I check if the token is expired and try to refresh it using the refresh token.

Issues: Refresh Token Not Provided: Sometimes, I do not receive the refresh token even after prompting for user consent.

Token Expiry Handling: When the access token expires, my application sometimes fails to refresh the token and requires user re-authentication.

Frequent Re-Authentication Prompts: Every time I authenticate, the application prompts me to choose an account and grant permissions again.

Context: I ensure to set access_type=offline and prompt for consent initially to get the refresh token.

I save both access and refresh tokens securely.

When checking if the token is expired, I attempt to refresh the access token using the refresh token.

Questions: How can I ensure that I always receive a refresh token from Google?

What is the best way to handle token expiration and refresh in my current setup?

Is there a more secure way to manage tokens, especially refresh tokens, to avoid re-authentication prompts?

How can I avoid being prompted to choose an account and grant permissions every time I authenticate?

Any guidance or suggestions would be greatly appreciated. Thank you!

func InitGoogleAuth() {
    err := godotenv.Load()
    if err != nil) {
        log.Fatalf("Error loading .env file: %v", err)
    }

    googleClientID := os.Getenv("GOOGLE_CLIENT_ID")
    googleClientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
    googleScopes := []string{
        "openid",
        "profile",
        "email",
        "https://www.googleapis.com/auth/drive.file",
        "https://www.googleapis.com/auth/drive.readonly",
        "https://www.googleapis.com/auth/drive",
    }

    store := sessions.NewCookieStore([]byte(key))
    store.Options = &sessions.Options{
        Path:     "/",
        MaxAge:   MaxAge,
        HttpOnly: true,
        Secure:   IsProd,
    }
    gothic.Store = store

    log.Println("Initializing Google provider with Client ID:", googleClientID)
    log.Println("Using Scopes:", googleScopes)

    // Initialize Google provider with updated scopes and access type
    googleProvider := google.New(googleClientID, googleClientSecret, "http://localhost:8080/v1/auth/google/callback", googleScopes...)
    googleProvider.SetAccessType("offline")
    googleProvider.SetPrompt("consent")
    goth.UseProviders(googleProvider)

    log.Println("Google provider initialized with scopes and offline access.")
}

func AuthCallback(c *gin.Context) {
    provider := c.Param("provider")
    type contextKey string
    const providerKey contextKey = "provider"
    ctx := context.WithValue(c.Request.Context(), providerKey, provider)
    c.Request = c.Request.WithContext(ctx)

    session, err := gothic.Store.Get(c.Request, "gothic-session")
    if err != nil {
        log.Println("Error retrieving session in AuthCallback:", err)
    } else {
        log.Println("Session retrieved in AuthCallback, session ID:", session.ID)
    }

    user, err := gothic.CompleteUserAuth(c.Writer, c.Request)
    if err != nil {
        c.String(http.StatusBadRequest, fmt.Sprint(err))
        return
    }

    token := &oauth2.Token{
        AccessToken:  user.AccessToken,
        RefreshToken: user.RefreshToken,
        Expiry:       user.ExpiresAt,
    }

    saveToken("token.json", token)

    var existingUser models.User
    err = db.DB.QueryRow("SELECT id, username, password, email FROM users WHERE email = ?", user.Email).Scan(&existingUser.ID, &existingUser.Username, &existingUser.Password, &existingUser.Email)
    if err == nil {
        log.Println("User already exists:", existingUser.Email)
        // Generate tokens for existing user
        accessToken, err := utils.GenerateAccessToken(existingUser.Username)
        if err != nil {
            log.Println("Error generating access token:", err)
            c.String(http.StatusInternalServerError, fmt.Sprintf("Error generating access token: %v", err))
            return
        }

        refreshToken, err := utils.GenerateRefreshToken(existingUser.Username)
        if err != nil {
            log.Println("Error generating refresh token:", err)
            c.String(http.StatusInternalServerError, fmt.Sprintf("Error generating refresh token: %v", err))
            return
        }

        c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("http://localhost:5173/auth/google/callback?email=%s&username=%s&access_token=%s&refresh_token=%s&id=%d", existingUser.Email, existingUser.Username, accessToken, refreshToken, existingUser.ID))
        return
    }

    c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("http://localhost:5173/choose-username?email=%s", user.Email))
}
func refreshAccessToken(tok *oauth2.Token) (*oauth2.Token, error) {
    config := &oauth2.Config{
        ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
        ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
        Scopes:       []string{drive.DriveScope},
        Endpoint:     google.Endpoint,
    }

    // Use the refresh token to get a new access token
    newToken, err := config.TokenSource(context.Background(), tok).Token()
    if err != nil {
        return nil, fmt.Errorf("unable to refresh token: %v", err)
    }

    // Save the new token to the file
    saveToken("token.json", newToken)

    return newToken, nil
}

What I Tried: Initial Setup: Configured Google OAuth2 with access_type=offline and SetPrompt("consent").

Token Management: Saved access tokens and refresh tokens in a token.json file.

Token Refresh Logic: Checked if the access token was expired and tried to refresh it using the refresh token.

Handling Expired Tokens: Attempted to refresh tokens during server startup and before making API calls.

What I Expected: To receive a refresh token consistently upon user consent.

To use the refresh token to obtain new access tokens without additional user prompts.

To handle token expiration seamlessly, avoiding frequent re-authentication requests.

What Actually Happened: Refresh Token Not Provided: Sometimes, I did not receive the refresh token even after prompting for user consent.

Token Expiry Handling: When the access token expired, my application sometimes failed to refresh the token, requiring user re-authentication.

Frequent Re-Authentication Prompts: Every time I authenticate, the application prompts me to choose an account and grant permissions again, which is not the user experience I was aiming for.

Upvotes: 1

Views: 216

Answers (1)

Linda Lawton - DaImTo
Linda Lawton - DaImTo

Reputation: 117321

You will get a refresh token back ever time a user grants you consent and you exchange the authorization code for an access token and refresh token if you have requested off line access.

googleProvider.SetAccessType("offline")

If you are refreshing your refresh token in order to get a new access token you will not always get a new refresh token back. If you have created a native desktop app i think you always get it back but with web applications its pot luck you get it sometimes but not everytime.

Make sure that you always store the newest refresh token.

token.json remember you will need one for each user. You should really be storing it in the database along with the users account. Store the time you last got one and then you can use that to decide if you need a new one or not.

Upvotes: -1

Related Questions