Google - v1.0.0
Callback
GoogleAuthController::callback() handles Google's redirect back to /google/auth/callback. It sits between Google's response and the persisted GoogleConnection row.
The controller
public function callback( Request $request ): RedirectResponse
{
if ( $error = $request->query( 'error' ) ) {
return $this->redirectAfterError()->with( 'google.error', (string) $error );
}
$code = (string) $request->query( 'code', '' );
$state = (string) $request->query( 'state', '' );
if ( '' === $code || '' === $state ) {
return $this->redirectAfterError()->with(
'google.error',
__( 'Google callback is missing required code or state parameter.' ),
);
}
try {
$this->oauth->handleCallback( $code, $state );
} catch ( OAuthException $e ) {
return $this->redirectAfterError()->with( 'google.error', $e->getMessage() );
}
return $this->redirectAfterConnect()->with( 'google.status', 'connected' );
}
Route: GET /google/auth/callback → google.auth.callback.
Error branches
The callback URL always ends up hitting one of three outcomes:
?error=…— the user denied consent or Google rejected something. Redirect toredirect_after_errorwithgoogle.error = <error code>.- Missing
codeorstate— malformed callback. Redirect toredirect_after_errorwith a translated message. OAuthExceptionfromhandleCallback()— state mismatch, missing PKCE verifier, or token exchange failed. Redirect toredirect_after_errorwith the exception message.
On success: redirect to redirect_after_connect with google.status = 'connected' flashed.
You can read the flashed values in your redirect target:
@if( session( 'google.error' ) )
<div class="alert alert-error">{{ session( 'google.error' ) }}</div>
@elseif( session( 'google.status' ) === 'connected' )
<div class="alert alert-success">Google account connected.</div>
@endif
handleCallback() internals
OAuthManager::handleCallback( $code, $state ) does the actual work:
1. Pull + validate session state
$storedState = $this->session->pull( self::SESSION_STATE );
$verifier = $this->session->pull( self::SESSION_VERIFIER );
$userId = $this->session->pull( self::SESSION_USER_ID );
The three session keys are all pull()ed, so subsequent replays of the callback URL find nothing and fail cleanly.
if ( empty( $storedState ) || ! hash_equals( (string) $storedState, $returnedState ) ) {
throw new OAuthException( __( 'OAuth state mismatch; possible CSRF attempt.' ) );
}
hash_equals() protects against timing attacks — never compare secrets with ===.
Missing verifier or user id also throw — this shouldn't happen unless the session was tampered with or the user hit /callback without going through /connect first.
2. Exchange the code
$response = $this->http->asForm()->post( $endpoint, [
'code' => $code,
'client_id' => $this->config->getClientId(),
'client_secret' => $this->config->getClientSecret(),
'redirect_uri' => $this->config->getRedirectUri(),
'grant_type' => 'authorization_code',
'code_verifier' => (string) $verifier,
] );
Failed exchanges throw OAuthException("Google code exchange failed: <error>"). Common <error> values:
| Error | Cause |
|---|---|
invalid_grant |
Code already used, expired, or the redirect URI on the token call doesn't match the one used on the authorize call. |
redirect_uri_mismatch |
The redirect URI in your credential driver doesn't match one of the URIs on the Google OAuth client. |
invalid_client |
Client ID or secret is wrong. |
3. Decode id_token
Because the openid scope is in the baseline, Google returns an id_token JWT alongside the access token. The manager decodes the payload (base64url, JSON) to extract:
sub→ the stable Google user id, stored asgoogle_user_id.email→ the email address, stored asemail.
$parts = explode( '.', $idToken );
$payload = base64_decode( strtr( $parts[ 1 ], '-_', '+/' ), true );
$claims = json_decode( $payload, true );
return [
$claims[ 'sub' ] ?? null,
$claims[ 'email' ] ?? null,
];
The JWT signature is not verified. The id_token arrived over TLS from Google's token endpoint on a connection we initiated — the claims are trustworthy for identity persistence (labeling the row so we can show email in the UI). We do not use them for authorization decisions, so signature validation would be pointless overhead. If your use case actually authorizes off the id_token, verify the signature externally with a library like firebase/php-jwt.
4. Persist the connection
$connection = GoogleConnection::firstOrNew( [ 'user_id' => $userId ] );
$connection->google_user_id = $googleUserId ?? $connection->google_user_id;
$connection->email = $email ?? $connection->email;
$connection->access_token = $payload[ 'access_token' ] ?? null;
$connection->token_type = $payload[ 'token_type' ] ?? 'Bearer';
$connection->scopes = $scopes;
$connection->expires_at = $expiresAt;
$connection->status = GoogleConnection::STATUS_CONNECTED;
$connection->disconnect_reason = null;
if ( ! empty( $payload[ 'refresh_token' ] ) ) {
$connection->refresh_token = $payload[ 'refresh_token' ];
}
$connection->save();
Key details:
firstOrNew— one connection per user. Re-connecting overwrites the existing row.- Refresh token preservation — the setter is gated behind
! empty( $payload[ 'refresh_token' ] ). Google only returns a refresh token on the first consent (and on subsequent consents whenprompt=consentis used with a new grant). Incremental-consent regrants typically omit it. If we nulled it out on every callback we'd silently disable refresh for the user; instead we keep whatever we already have on file. - Access + refresh token encryption — handled by the model's
casts()returning'encrypted'. The DB sees ciphertext. - Scopes — Google returns the effective scope list in the
scopefield of the response (space-separated). We split on' 'and store as a JSON array; if the field is missing, we fall back to the union the scope registry returned. expires_at— computed asnow() + expires_inseconds. If Google doesn't includeexpires_in(extremely rare), we storenull, which the token manager treats as "expired" and triggers a refresh on next use.disconnect_reason = null— a re-connect clears the "why was this disconnected?" note from any prior disconnect.
Debugging a failing callback
Enable Laravel Boost's read-log-entries tool or run php artisan pail and repeat the flow. The most common failure signals:
OAuthException: OAuth state mismatch→ sessions aren't sticking across the redirect. CheckSESSION_DRIVER, cookie domain,SESSION_SAME_SITE, HTTPS.OAuthException: PKCE code verifier missing from session→ same as above, or the session was flushed between/connectand/callback.OAuthException: Google code exchange failed: invalid_grant→ the code was reused (double-click on "Allow"?) or the redirect URIs disagree.OAuthException: Google code exchange failed: redirect_uri_mismatch→ the URL stored in your credential driver doesn't match what's registered on the Google OAuth client. Character-exact match required.