Authorization Server & Social Login
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page has two halves that often ship together. The first builds an authorization server — your own
OAuth2 / OpenID Connect issuer — with Spring Authorization Server, so your applications get login, logout,
and access / refresh / ID tokens from an endpoint you control. The second adds social login — "Sign in with
Google / Microsoft / Apple" — with oauth2Login(), where your app is the client of someone else’s issuer and
you reconcile the external identity against a local account. It builds on
Spring Security: the filter chain, method security, and the
SecurityContext all work exactly as described there. The REST endpoints below follow
REST APIs, the YAML is bound per-environment as in
Configuration & Profiles, and flows are exercised in
tests with spring-security-test and MockMvcTester / WebTestClient — see
Unit & Integration Testing.
For the protocol underneath all of this — which grant type to expose and why (Choosing an OAuth Flow), and what each authorization- and token-request parameter does, including why PKCE is now required even for confidential clients (Authorization Code and PKCE) — see the OAuth Reference.
When to build your own
An authorization server issues tokens for your client applications and APIs. Build one with Spring
Authorization Server when you own the user identities, need full control over token contents, consent, and
client registration, and want no external dependency in the login path. If you would be happy delegating all of
that, run Keycloak or a managed IdP (Auth0, Okta, Microsoft Entra ID, AWS Cognito) instead and make your
services plain resource servers and oauth2Login clients against it — far less to operate.
Spring Authorization Server has moved into Spring Security 7.0. Depend on
org.springframework.security:spring-security-oauth2-authorization-server, or the Boot starter
spring-boot-starter-oauth2-authorization-server. Two API changes matter when following older material: the
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) helper is gone — use
OAuth2AuthorizationServerConfigurer.authorizationServer() — and PKCE is required by default for
authorization_code, including for confidential clients unless you opt out per client.
Configuring the authorization server
Two SecurityFilterChain beans, ordered. The first matches only the protocol endpoints; the second is the
app’s own chain that actually authenticates the end user.
@Configuration
public class AuthorizationServerConfig {
@Bean
@Order(1)
SecurityFilterChain authorizationServerChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServer =
OAuth2AuthorizationServerConfigurer.authorizationServer();
http
.securityMatcher(authorizationServer.getEndpointsMatcher())
.with(authorizationServer, server -> server
.oidc(Customizer.withDefaults())) // enable the OIDC endpoints
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())); // for /userinfo, /connect/register
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain appChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults()); // and/or .oauth2Login(...) for social login
return http.build();
}
}
The end user is authenticated by a UserDetailsService — reuse the DB-backed one from
Spring Security ("Building the SecurityContext from credentials").
formLogin (and /logout) belong to the @Order(2) chain; every /oauth2/ and /connect/ endpoint below
belongs to the @Order(1) chain, and a browser hitting /oauth2/authorize while unauthenticated is redirected
to /login by the entry point above.
Default endpoints
| Endpoint | Purpose |
|---|---|
|
Authorization endpoint — starts |
|
Token endpoint — exchanges a code (or refresh token) for access / refresh / ID tokens |
|
JSON Web Key Set — public keys resource servers use to verify signatures |
|
Token revocation (RFC 7009) |
|
Token introspection (RFC 7662) — for opaque-token resource servers |
|
OIDC Dynamic Client Registration (enabled via |
|
OIDC UserInfo — claims about the authenticated user, called with the access token |
|
OIDC RP-initiated logout |
|
OIDC discovery document — clients read every URL above from here |
Beans to implement
@Bean
RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate); // InMemoryRegisteredClientRepository for demos
}
@Bean
OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository clients) {
return new JdbcOAuth2AuthorizationService(jdbcTemplate, clients);
}
@Bean
OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate,
RegisteredClientRepository clients) {
return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clients);
}
@Bean
JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey(); // 2048-bit RSA, loaded from a keystore in prod
RSAKey rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey((RSAPrivateKey) keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
return new ImmutableJWKSet<>(new JWKSet(rsaKey));
}
@Bean
JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer("https://auth.example.com")
.build();
}
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(AppUserRepository users) {
return context -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
String username = context.getPrincipal().getName();
Set<String> roles = users.findByUsername(username)
.map(AppUser::getAuthorities).orElse(Set.of());
context.getClaims().claim("roles", roles);
}
};
}
For production, keep the RSA keypair in a keystore (or a KMS / vault) and load it, so restarts and multiple instances share the same signing key.
Registered clients
A RegisteredClient describes one application allowed to request tokens. A confidential client authenticates
with a secret; a public client (SPA, mobile, CLI) cannot keep a secret and must use PKCE.
RegisteredClient webApp = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("web-app")
.clientSecret("{bcrypt}$2a$10$...") // client_secret_basic
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://app.example.com/login/oauth2/code/web-app")
.postLogoutRedirectUri("https://app.example.com/")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("read")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(30))
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // JWT; REFERENCE = opaque
.build())
.build();
RegisteredClient spa = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("spa")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // public client
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://spa.example.com/callback")
.scope(OidcScopes.OPENID)
.clientSettings(ClientSettings.builder()
.requireProofKey(true) // PKCE required
.build())
.build();
The same two clients as configuration properties (Boot binds them into an
InMemoryRegisteredClientRepository when no RegisteredClientRepository bean is defined):
spring:
security:
oauth2:
authorizationserver:
issuer: https://auth.example.com
client:
web-app:
registration:
client-id: web-app
client-secret: "{bcrypt}$2a$10$..."
client-authentication-methods: client_secret_basic
authorization-grant-types: [authorization_code, refresh_token]
redirect-uris: [https://app.example.com/login/oauth2/code/web-app]
post-logout-redirect-uris: [https://app.example.com/]
scopes: [openid, profile, read]
require-authorization-consent: true
token:
access-token-time-to-live: 15m
refresh-token-time-to-live: 30d
spa:
registration:
client-id: spa
client-authentication-methods: none
authorization-grant-types: [authorization_code]
redirect-uris: [https://spa.example.com/callback]
scopes: [openid]
require-proof-key: true
Database model
Spring Authorization Server ships three JDBC scripts on the classpath
(org/springframework/security/oauth2/server/authorization/…):
| Script | Table | Holds |
|---|---|---|
|
|
one row per registered client (id, secret, grant types, scopes, |
|
|
one row per grant — the authorization state plus the authorization-code, access-token, refresh-token, and id-token columns (value, issued/expires, metadata) |
|
|
one row per (client, principal) — the set of scopes the user has already consented to |
Plus the users / authorities tables from Spring Security for
the end-user formLogin.
CREATE TABLE oauth2_registered_client (
id varchar(100) NOT NULL,
client_id varchar(100) NOT NULL,
client_id_issued_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
client_secret varchar(200) DEFAULT NULL,
client_secret_expires_at timestamp DEFAULT NULL,
client_name varchar(200) NOT NULL,
client_authentication_methods varchar(1000) NOT NULL,
authorization_grant_types varchar(1000) NOT NULL,
redirect_uris varchar(1000) DEFAULT NULL,
post_logout_redirect_uris varchar(1000) DEFAULT NULL,
scopes varchar(1000) NOT NULL,
client_settings varchar(2000) NOT NULL,
token_settings varchar(2000) NOT NULL,
PRIMARY KEY (id)
);
spring:
sql:
init:
mode: always
schema-locations:
- classpath:org/springframework/security/oauth2/server/authorization/oauth2-registered-client-schema.sql
- classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql
- classpath:org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql
- classpath:org/springframework/security/core/userdetails/jdbc/users.ddl
datasource:
url: jdbc:postgresql://localhost:5432/auth
username: auth
password: auth
Use spring.sql.init only for dev / demo; in staging and production run these scripts through a migration tool
(see Evolving the Database Model) and set mode: never.
Login and logout
.oidc(Customizer.withDefaults()) plus the formLogin(withDefaults()) on the @Order(2) chain gives you a
generated login page at /login. To brand it, register your own /login @Controller returning a template and
point formLogin().loginPage("/login") at it.
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login"; // src/main/resources/templates/login.html
}
}
RP-initiated logout: a client sends the browser to /connect/logout?id_token_hint=…&post_logout_redirect_uri=…;
the authorization server ends its session and redirects back to a postLogoutRedirectUri registered on that
client. Revocation: a client POSTs a token to /oauth2/revoke (with its client credentials) to invalidate it
before it expires.
A client round-trip
authorization_code + PKCE by hand, against the running server:
# 1. generate a PKCE pair
CODE_VERIFIER=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
# 2. open the authorization URL in a browser; log in and consent
open "https://auth.example.com/oauth2/authorize?response_type=code&client_id=spa\
&redirect_uri=https://spa.example.com/callback&scope=openid+profile\
&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"
# 3. exchange the returned ?code=... for tokens
curl -s https://auth.example.com/oauth2/token \
-d grant_type=authorization_code \
-d client_id=spa \
-d redirect_uri=https://spa.example.com/callback \
-d code_verifier="${CODE_VERIFIER}" \
-d code="AUTH_CODE_FROM_STEP_2"
# 4. inspect the server's keys and discovery document
curl -s https://auth.example.com/oauth2/jwks
curl -s https://auth.example.com/.well-known/openid-configuration
scope, code_challenge, S256) B->>AS: GET /oauth2/authorize AS->>B: 302 to /login (not authenticated) B->>AS: POST /login (username + password) AS->>B: consent screen (first time for this client + scopes) B->>AS: approve AS->>B: 302 to redirect_uri with code=xyz B->>C: GET redirect_uri with code=xyz C->>AS: POST /oauth2/token (code, code_verifier) AS->>C: access_token (JWT) + refresh_token + id_token C->>RS: GET /api/orders with bearer access_token RS->>AS: GET /oauth2/jwks (cached) -- verify signature, iss, aud, exp RS->>C: 200 resource
Social login (Google, Microsoft, Apple)
Account linking, the verified-e-mail trap, per-provider quirks and the broker pattern are covered protocol-side in Social Login and Federation.
Here your app is the client. Add spring-boot-starter-oauth2-client and enable oauth2Login; add
oauth2Client(…) as well only when you also call provider APIs outside an interactive login.
@Bean
SecurityFilterChain socialLogin(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/api/auth/providers").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService())))
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
Properties
spring.security.oauth2.client.registration.<id>. — client-id, client-secret,
client-authentication-method, authorization-grant-type, redirect-uri
({baseUrl}/login/oauth2/code/{registrationId}), scope, client-name, provider.
spring.security.oauth2.client.provider.<id>. — issuer-uri (discovery), or explicit
authorization-uri / token-uri / user-info-uri / jwk-set-uri / user-name-attribute.
Classes involved
ClientRegistration / ClientRegistrationRepository / InMemoryClientRegistrationRepository hold the
per-provider config; CommonOAuth2Provider (GOOGLE, GITHUB, FACEBOOK, X, OKTA) supplies preset
endpoint URLs. OAuth2LoginAuthenticationFilter handles the callback at
/login/oauth2/code/{registrationId}. OidcUserService (for OIDC providers) and DefaultOAuth2UserService
(plain OAuth2) fetch the user; they produce an OidcUser / OAuth2User carrying OidcUserAuthority /
OAuth2UserAuthority. A GrantedAuthoritiesMapper bean remaps those to your own authorities.
Interface to implement
Delegate to the built-in OidcUserService, then find-or-create a local AppUser and return a
DefaultOidcUser with local authorities:
@Bean
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
OidcUserService delegate = new OidcUserService();
return userRequest -> {
OidcUser oidcUser = delegate.loadUser(userRequest);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String subject = oidcUser.getSubject();
String email = oidcUser.getEmail();
boolean emailVerified = Boolean.TRUE.equals(oidcUser.getEmailVerified());
AppUser appUser = accountService.findOrCreate(registrationId, subject,
emailVerified ? email : null, oidcUser.getFullName());
Set<GrantedAuthority> authorities = appUser.getAuthorities().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(HashSet::new));
authorities.add(new OidcUserAuthority(oidcUser.getIdToken(), oidcUser.getUserInfo()));
return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo(),
StandardClaimNames.SUB);
};
}
Per-provider config
Google — OIDC discovery works out of the box:
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: [openid, profile, email]
provider:
google:
issuer-uri: https://accounts.google.com
With spring-boot-starter-oauth2-client on the classpath, the google registration id also picks up
CommonOAuth2Provider.GOOGLE automatically when only client-id / client-secret are set.
Microsoft Entra ID — a custom provider; preferred_username is the stable login name:
spring:
security:
oauth2:
client:
registration:
entra:
client-id: ${ENTRA_CLIENT_ID}
client-secret: ${ENTRA_CLIENT_SECRET}
scope: [openid, profile, email]
client-name: Microsoft
provider: entra
provider:
entra:
issuer-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/v2.0
# or explicit:
# authorization-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize
# token-uri: https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token
# user-info-uri: https://graph.microsoft.com/oidc/userinfo
user-name-attribute: preferred_username
spring-cloud-azure-starter-active-directory is the higher-level alternative — it wires app roles, group
claims, and On-Behalf-Of flows without hand-writing the provider block.
Apple — "Sign in with Apple" issues no client secret. Authentication is private_key_jwt: a
client-assertion JWT signed with the ES256 key downloaded from the Apple developer portal (key id + team id
service id). Apple returns the user’s name and email only on the first authorization, so persist them then;
later logins carry only sub.
spring:
security:
oauth2:
client:
registration:
apple:
client-id: ${APPLE_SERVICE_ID} # the Services ID, not the App ID
client-authentication-method: private_key_jwt
authorization-grant-type: authorization_code
scope: [openid, name, email]
provider: apple
provider:
apple:
issuer-uri: https://appleid.apple.com
user-name-attribute: sub
Plain auto-config does not build the ES256 client-assertion, so Apple needs a custom
OAuth2AuthorizationCodeGrantRequestEntityConverter (and a matching token-response client) that signs the
assertion from your ES256 JWK, wired via a RestClientAuthorizationCodeTokenResponseClient.
Local account model and endpoints
One local user, several linked providers:
CREATE TABLE app_user (
id bigserial PRIMARY KEY,
email varchar(320) UNIQUE,
display_name varchar(200),
enabled boolean NOT NULL DEFAULT true
);
CREATE TABLE user_identity (
id bigserial PRIMARY KEY,
provider varchar(50) NOT NULL, -- 'google' | 'entra' | 'apple' | 'local'
provider_user_id varchar(255) NOT NULL, -- the 'sub' claim
user_id bigint NOT NULL REFERENCES app_user (id),
linked_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (provider, provider_user_id)
);
@Entity
@Table(name = "app_user")
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String email;
private String displayName;
private boolean enabled = true;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "app_user_authority", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "authority")
private Set<String> authorities = new HashSet<>();
// getters / setters
}
@Entity
@Table(name = "user_identity",
uniqueConstraints = @UniqueConstraint(columnNames = {"provider", "provider_user_id"}))
public class UserIdentity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String provider;
private String providerUserId;
@ManyToOne(optional = false)
@JoinColumn(name = "user_id")
private AppUser user;
private Instant linkedAt = Instant.now();
// getters / setters
}
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByEmail(String email);
}
public interface UserIdentityRepository extends JpaRepository<UserIdentity, Long> {
Optional<UserIdentity> findByProviderAndProviderUserId(String provider, String providerUserId);
}
See Spring Data JPA for the repository mechanics. findOrCreate
(called from the OAuth2UserService above) looks up user_identity by (provider, sub), falls back to
matching a verified email, and otherwise inserts a new app_user + user_identity row.
@RestController
@RequestMapping("/api")
public class AccountController {
// InMemoryClientRegistrationRepository implements Iterable<ClientRegistration>; it is what Boot
// creates when registrations come from spring.security.oauth2.client.registration.* properties.
// The ClientRegistrationRepository interface itself is not iterable -- inject the concrete type
// here, or keep your own List<ProviderInfo> if you use a different repository implementation.
private final InMemoryClientRegistrationRepository clientRegistrations;
private final AppUserRepository users;
public AccountController(InMemoryClientRegistrationRepository clientRegistrations, AppUserRepository users) {
this.clientRegistrations = clientRegistrations;
this.users = users;
}
// login UI reads this to render the provider buttons
@GetMapping("/auth/providers")
public List<ProviderInfo> providers() {
List<ProviderInfo> list = new ArrayList<>();
clientRegistrations.forEach(reg -> list.add(
new ProviderInfo(reg.getRegistrationId(), reg.getClientName(),
"/oauth2/authorization/" + reg.getRegistrationId())));
return list;
}
@GetMapping("/me")
public MeResponse me(@AuthenticationPrincipal OidcUser principal) {
AppUser user = users.findByEmail(principal.getEmail()).orElseThrow();
return new MeResponse(user.getId(), user.getEmail(), user.getDisplayName(), user.getAuthorities());
}
}
Do not re-implement the redirect entry point: GET /oauth2/authorization/{registrationId} is built into Spring
Security and starts the flow; the callback GET /login/oauth2/code/{registrationId} is handled by
OAuth2LoginAuthenticationFilter. POST /logout is the standard Spring Security logout.
Calling provider and downstream APIs
After login, the access token for each provider is stored as an OAuth2AuthorizedClient (keyed by
registration id + principal) in an OAuth2AuthorizedClientService / OAuth2AuthorizedClientRepository. An
OAuth2AuthorizedClientManager hands it out and refreshes it when expired.
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrations,
OAuth2AuthorizedClientRepository authorizedClients) {
OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.build();
DefaultOAuth2AuthorizedClientManager manager =
new DefaultOAuth2AuthorizedClientManager(clientRegistrations, authorizedClients);
manager.setAuthorizedClientProvider(provider);
return manager;
}
// blocking: RestClient + OAuth2ClientHttpRequestInterceptor (Spring Security 7)
@Bean
RestClient googleRestClient(OAuth2AuthorizedClientManager manager) {
OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(manager);
interceptor.setClientRegistrationIdResolver(request -> "google");
return RestClient.builder()
.baseUrl("https://www.googleapis.com")
.requestInterceptor(interceptor)
.build();
}
// reactive: WebClient + ServerOAuth2AuthorizedClientExchangeFilterFunction
@Bean
WebClient googleWebClient(ReactiveClientRegistrationRepository clientRegistrations,
ServerOAuth2AuthorizedClientRepository authorizedClients) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2 =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(clientRegistrations, authorizedClients);
oauth2.setDefaultClientRegistrationId("google");
return WebClient.builder()
.baseUrl("https://www.googleapis.com")
.filter(oauth2)
.build();
}
A plain (unauthenticated) RestClient / WebClient GET is enough to read a provider’s jwk-set-uri or
/.well-known/openid-configuration.
scope openid profile email, state, nonce, PKCE) B->>G: sign in + consent G->>B: 302 to /login/oauth2/code/google (code, state) B->>A: GET /login/oauth2/code/google (code) A->>G: POST token endpoint (code, PKCE verifier) G->>A: id_token + access_token A->>G: GET userinfo (access_token) G->>A: sub, email, email_verified, name A->>DB: find user_identity by (google, sub) opt no local link yet A->>DB: match verified email, else INSERT app_user + user_identity end A->>B: session established, then 302 to original request