@molecule/api-oauth-microsoft
Provider bond · auth · API (Node) · v1.0.2 · Apache-2.0
Microsoft Identity Platform OAuth provider for molecule.dev.
npm install @molecule/api-oauth-microsoftnpm · Source on GitHub · Implements @molecule/api-oauth
How it works
@molecule/api-oauth-microsoft is a provider bond on the API (Node) side: it implements the auth core interface (@molecule/api-oauth) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { bond } from '@molecule/api-bond'
import * as microsoft from '@molecule/api-oauth-microsoft'
bond('oauth', microsoft.serverName, microsoft)Works with: @molecule/api-bond, @molecule/api-http, @molecule/api-oauth, @molecule/api-secrets
Secrets: OAUTH_MICROSOFT_CLIENT_ID, OAUTH_MICROSOFT_CLIENT_SECRET, OAUTH_MICROSOFT_TENANT_ID (optional)
Reference
Microsoft Identity Platform OAuth provider for molecule.dev.
Implements the @molecule/api-oauth contract (serverName + verify)
for compatibility with the existing OAuth bond wiring, plus a richer
OAuthProvider surface (getAuthorizationUrl,
exchangeCodeForTokens, getUserInfo, refreshAccessToken,
verifyIdToken) for apps that need OpenID Connect, refresh-token
rotation, or Microsoft Graph access (Outlook, OneDrive, Teams).
Setup
-
Sign in to the Microsoft Entra admin center.
-
Navigate to Applications → App registrations → New registration.
- Choose Accounts in any organizational directory and personal
Microsoft accounts for multi-tenant + consumer login (matches
the default
OAUTH_MICROSOFT_TENANT_ID=common). For single-tenant apps, choose Accounts in this organizational directory only and setOAUTH_MICROSOFT_TENANT_IDto the directory tenant id. - Add a Web redirect URI matching your app origin
(e.g.,
http://localhost:3000for development, your production origin otherwise — must match theredirectUripassed togetAuthorizationUrlandexchangeCodeForTokens).
- Choose Accounts in any organizational directory and personal
Microsoft accounts for multi-tenant + consumer login (matches
the default
-
After registration:
-
Copy the Application (client) ID to your API's
OAUTH_MICROSOFT_CLIENT_IDenvironment variable. -
Open Certificates & secrets → Client secrets → New client secret, then copy the secret value (NOT the secret id) to your API's
OAUTH_MICROSOFT_CLIENT_SECRETenvironment variable. -
For single-tenant apps, also set
OAUTH_MICROSOFT_TENANT_IDto the directory tenant id.
-
-
Under API permissions, add the delegated Microsoft Graph permissions you need. The defaults granted by the
openid email profile User.Readscope suffice for sign-in. -
Restart your API and/or rebuild your app so they pick up the environment variables.
Your users should now be able to log in via Microsoft!
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { bond } from '@molecule/api-bond'
import * as microsoft from '@molecule/api-oauth-microsoft'
bond('oauth', microsoft.serverName, microsoft)
Type
provider
Installation
npm install @molecule/api-oauth-microsoft @molecule/api-bond @molecule/api-http @molecule/api-oauth @molecule/api-secrets
API
Interfaces
JsonWebKey
RFC 7517 JSON Web Key (RS256 subset).
interface JsonWebKey {
kty: string
use?: string
kid: string
n: string
e: string
alg?: string
x5c?: string[]
}
JwksResponse
Discovery response shape for the /discovery/v2.0/keys endpoint.
interface JwksResponse {
keys: JsonWebKey[]
}
OAuthUserInfo
Normalized user info returned by OAuth providers.
Matches the shape used across bonds — id/email/name/picture — derived
from OAuthUserProps so consumers do not need provider-specific shapes.
interface OAuthUserInfo {
/** Unique identifier from the provider. */
id: string
/** User's email address, when available. */
email?: string
/** User's display name, when available. */
name?: string
/** URL of the user's profile picture, when available. */
picture?: string
}
MicrosoftOAuthConfig
Optional configuration for the Microsoft OAuth provider.
tenantId overrides the path segment in Microsoft endpoints. Defaults
to "common" (multi-tenant + personal accounts). For single-tenant
apps, pass the directory tenant GUID.
interface MicrosoftOAuthConfig {
/**
* Microsoft tenant identifier. Defaults to `"common"`.
*
* Common values: `"common"`, `"organizations"`, `"consumers"`, or a
* tenant GUID such as `"00000000-0000-0000-0000-000000000000"`.
*/
tenantId?: string
/** Override OAuth client ID. Defaults to `process.env.OAUTH_MICROSOFT_CLIENT_ID`. */
clientId?: string
/** Override OAuth client secret. Defaults to `process.env.OAUTH_MICROSOFT_CLIENT_SECRET`. */
clientSecret?: string
/**
* Default OAuth scopes when none are passed to `getAuthorizationUrl`.
* Defaults to `'openid email profile User.Read'`.
*/
defaultScope?: string
}
MicrosoftTokenSet
Token response from the Microsoft /token endpoint.
Microsoft rotates refresh tokens — always persist whatever
refresh_token value is returned, replacing the previous one.
interface MicrosoftTokenSet {
/** Bearer access token used for Microsoft Graph requests. */
accessToken: string
/** OpenID Connect ID token (JWT) for `verifyIdToken`. */
idToken?: string
/** Refresh token (rotated by Microsoft on every refresh). */
refreshToken?: string
/** Token type (typically `"Bearer"`). */
tokenType: string
/** Lifetime of `accessToken` in seconds, when reported. */
expiresIn?: number
/** Granted scope string (space-delimited). */
scope?: string
}
MicrosoftIdTokenClaims
Verified ID token claims returned by verifyIdToken.
Only the validated subset of OpenID claims is exposed — the full raw
payload is included as claims for callers that need additional
fields, but only after signature/issuer/audience/expiry checks pass.
interface MicrosoftIdTokenClaims {
/** Subject identifier (Microsoft user object id). */
sub: string
/** Issuer URL. */
iss: string
/** Audience (the OAuth client ID). */
aud: string
/** Expiry (epoch seconds). */
exp: number
/** Issued-at (epoch seconds). */
iat: number
/** Email address, when present. */
email?: string
/** Preferred username (often the UPN), when present. */
preferred_username?: string
/** Display name, when present. */
name?: string
/** Tenant id from the token. */
tid?: string
/** Object id from the token. */
oid?: string
/** Full validated payload for additional non-standard claims. */
claims: Record<string, unknown>
}
OAuthProvider
Microsoft Identity Platform OAuth provider surface.
Higher-level than the core OAuthVerifier contract — exposes the full
OAuth 2.0 / OpenID Connect lifecycle (auth URL, token exchange,
refresh, id-token verification) needed by apps that integrate Outlook,
OneDrive, Teams, or any Graph API.
interface OAuthProvider {
/** OAuth server identifier (`"microsoft"`). */
readonly serverName: string
/**
* Build the authorization URL the user is redirected to.
* @param params - Authorization request parameters.
* @returns Fully-qualified URL to begin the OAuth flow.
*/
getAuthorizationUrl(params: { state: string; redirectUri: string; scope?: string }): string
/**
* Exchange an authorization code for an access/id/refresh token set.
*
* When the authorization request carried a PKCE `code_challenge`, the
* matching `codeVerifier` MUST be supplied — the Microsoft identity
* platform requires `code_verifier` at redemption for such codes and
* otherwise rejects with `invalid_grant` (AADSTS501481).
*
* @param code - Authorization code returned by Microsoft.
* @param redirectUri - Redirect URI matching the authorization request.
* @param codeVerifier - PKCE code verifier matching the authorization
* request's `code_challenge`, when PKCE was used.
* @returns The token set.
*/
exchangeCodeForTokens(
code: string,
redirectUri: string,
codeVerifier?: string,
): Promise<MicrosoftTokenSet>
/**
* Fetch normalized user info from Microsoft Graph (`/v1.0/me`).
* @param accessToken - Access token previously obtained.
* @returns Normalized user info.
*/
getUserInfo(accessToken: string): Promise<OAuthUserInfo>
/**
* Use a refresh token to obtain a fresh access/id/refresh token set.
*
* Microsoft rotates refresh tokens — the returned token set's
* `refreshToken` (when present) supersedes the input token.
* @param refreshToken - Existing refresh token.
* @returns New token set.
*/
refreshAccessToken(refreshToken: string): Promise<MicrosoftTokenSet>
/**
* Verify a Microsoft-issued ID token (RS256), validating signature
* via JWKS plus `iss`, `aud`, and `exp` claims.
*
* JWKS is fetched from the discovery endpoint and cached in memory
* for one hour.
*
* @param idToken - The compact JWS string to verify.
* @returns Validated claims.
*/
verifyIdToken(idToken: string): Promise<MicrosoftIdTokenClaims>
}
OAuthUserProps
The properties returned when verifying an OAuth code.
interface OAuthUserProps {
/**
* An alphanumeric username derived from the OAuth provider.
*
* Format: `{provider_username}@{provider_name}`
*/
username: string
/**
* The user's display name from the OAuth provider.
*/
name?: string
/**
* The user's short biography / description from the OAuth provider
* (e.g. GitHub's `bio`, GitLab's `bio`, X's `description`). Omitted when
* the provider exposes no such field.
*/
bio?: string
/**
* URL of the user's profile image from the OAuth provider (e.g. Google's
* `picture`, GitHub/GitLab's `avatar_url`, X's `profile_image_url`).
* Omitted when the provider exposes none (Apple never does; Microsoft
* Graph only serves photos as a binary endpoint behind an extra scope).
*/
avatar?: string
/**
* The user's email address from the OAuth provider.
*/
email?: string
/**
* Whether the OAuth provider has affirmatively verified that the user
* controls this `email` mailbox.
*
* `true` MUST mean the provider proved mailbox ownership (e.g. Google's
* `email_verified`, Apple's `email_verified` ID-token claim). When the
* provider exposes no trustworthy verification signal in the profile data
* the verifier fetched, this MUST be `false`/`undefined` (never optimistically
* `true`) — consumers treat only an explicit `true` as verified.
*
* Consumers (e.g. the user resource's `logInOAuth` handler) use this to
* decide whether a provider-supplied email may be trusted over an existing,
* unverified local account — preventing an unverified squatter from blocking
* the verified mailbox owner.
*/
emailVerified?: boolean
/**
* The OAuth server identifier (e.g., 'github', 'google', 'twitter').
*/
oauthServer: string
/**
* Unique identifier for the user from the OAuth provider.
*/
oauthId: string
/**
* Raw user data from the OAuth provider.
*/
oauthData: Record<string, unknown>
}
OAuthAuthorizeUrlParams
Parameters for building a provider authorization (initiation) URL — the URL the user's browser is redirected to so the provider can authenticate them and send back an authorization code.
interface OAuthAuthorizeUrlParams {
/**
* Absolute URI the provider should redirect the user back to after
* authorization (the app origin, optionally with a path). When omitted,
* the builder leaves `redirect_uri` off the URL so the provider falls
* back to its registered callback URL.
*/
redirectUri?: string
/**
* The CSRF `state` parameter bound to the initiating session (stored in
* an httpOnly cookie by the initiation endpoint and validated by the
* login handler on callback).
*/
state: string
/**
* PKCE code challenge derived (S256) from the per-session code verifier.
* Omit only for providers that do not support PKCE.
*/
codeChallenge?: string
/**
* PKCE challenge method. Always prefer `'S256'`; `'plain'` exists only
* for providers that cannot hash.
*/
codeChallengeMethod?: 'S256' | 'plain'
}
Types
OAuthVerifier
Exchanges an OAuth authorization code for user profile information.
Implementations call the provider's token and user-info endpoints,
then return normalized OAuthUserProps for account creation or login.
Returning null means the provider AFFIRMATIVELY rejected the code
(e.g. GitHub's bad_verification_code, an expired/forged code) — the
consumer (logInOAuth) surfaces that as a clean 403 "verification
failed". A thrown error means an infrastructure failure (network,
provider outage) and surfaces as a 500. Implementations MUST NOT throw
for a rejected code — that would misreport a client mistake (or an
attack) as a server fault.
type OAuthVerifier = (
code: string,
codeVerifier?: string,
redirectUri?: string,
) => Promise<OAuthUserProps | null>
code— The authorization code received from the OAuth redirect.codeVerifier— The PKCE code verifier, if the flow uses proof-key exchange.redirectUri— The redirect URI that was used in the authorization request.
OAuthAuthorizeUrlBuilder
Builds the provider's authorization URL for OAuth initiation
(GET /users/oauth/:provider → 302 to this URL). Implementations embed
their own client id, scopes, and authorize endpoint so no consumer ever
hardcodes provider knowledge.
type OAuthAuthorizeUrlBuilder = (params: OAuthAuthorizeUrlParams) => string | null
params— State, PKCE challenge, and optional redirect URI.
Returns: The absolute authorization URL, or null when the provider is not configured (e.g. its client id env var is unset).
Classes
MicrosoftOAuthCodeRejectedError
Thrown by exchangeCodeForTokens when the Microsoft identity platform
v2.0 token endpoint AFFIRMATIVELY rejects the authorization code or PKCE
verifier — HTTP 400 with {"error":"invalid_grant"} (e.g. AADSTS70008
expired/already-redeemed code, AADSTS501481 code_verifier mismatch).
This is a client-side failure (or an attack), NOT an infrastructure
fault: verify catches this error and returns null per the
OAuthVerifier contract so the consumer responds with a clean 403
"verification failed" instead of a misleading 500.
class MicrosoftOAuthCodeRejectedError extends Error {
/**
* Creates the typed rejection error thrown for a 400 `invalid_grant`.
*
* @param message - Sanitized (secret-redacted) description of the rejection.
*/
constructor(message: string) {
super(message)
this.name = 'MicrosoftOAuthCodeRejectedError'
}
}
Functions
jwksUrlFor(tenantId)
Build the JWKS discovery URL for a given Microsoft tenant.
function jwksUrlFor(tenantId: string): string
tenantId— Microsoft tenant identifier (e.g.,"common").
Returns: The fully-qualified JWKS endpoint URL.
getJwks(tenantId, options)
Fetch (and cache) the JWKS document for a Microsoft tenant.
Cache TTL is one hour. Pass force: true (e.g., on a key-id miss)
to bypass the cache and refresh.
function getJwks(
tenantId: string,
options?: { force?: boolean; now?: () => number },
): Promise<JsonWebKey[]>
tenantId— Microsoft tenant identifier.options— Optional overrides.
Returns: The list of JWKs from the tenant.
clearJwksCache()
Clear the in-memory JWKS cache (test-only / forced refresh).
function clearJwksCache(): void
verifyRs256Signature(jwk, signingInput, signature)
Verify an RS256 signature against the supplied JWK.
function verifyRs256Signature(jwk: JsonWebKey, signingInput: string, signature: string): boolean
jwk— JSON Web Key withkty: "RSA"andalg: "RS256".signingInput— Concatenated${header}.${payload}string.signature— Base64url-encoded signature segment.
Returns: true if the signature verifies.
allowedIssuers(tenantId, tokenTid)
Acceptable issuer URLs for a given Microsoft tenant.
The configured tenantId decides whether the token's own tid may
resolve the accepted issuer:
- Single-tenant pin (a concrete tenant GUID): the issuer is fixed to
that exact tenant. The token's
tidis NEVER used to widen the accepted issuer set — Microsoft's public-cloud signing keys are shared across all tenants, so a validly-signed token from a different tenant would otherwise pass a single-tenant pin (a cross-tenant authentication bypass). The caller's separatetid-vs-config check (seeverifyMicrosoftIdToken) is what enforces the tenant; this function does not silently trust an attacker-suppliedtid. - Multi-tenant authority (
common/organizations/consumers): Microsoft's v2.0 issuer is the templated formhttps://login.microsoftonline.com/{tid}/v2.0, where{tid}is the signing tenant. Here accepting the token'stid-derived issuer IS the documented contract (the app intentionally allows any directory), so it is permitted.
function allowedIssuers(tenantId: string, tokenTid?: string): string[]
tenantId— The configured tenant id ("common"by default).tokenTid— Thetidclaim from the token, when present.
Returns: A list of acceptable iss values.
verifyMicrosoftIdToken(idToken, config, options)
Verify a Microsoft-issued ID token end-to-end (signature, issuer, audience, expiry).
function verifyMicrosoftIdToken(
idToken: string,
config: { tenantId: string; audience: string },
options?: { now?: () => number; refreshOnMiss?: boolean },
): Promise<MicrosoftIdTokenClaims>
idToken— Compact JWS.config— Tenant + audience config.options— Optional clock + JWKS refresh hooks (test seams).
Returns: Validated claims.
resolveConfig(overrides)
Resolve effective configuration by layering an explicit config over environment variables. Pure — does no I/O.
function resolveConfig(overrides?: MicrosoftOAuthConfig): {
tenantId: string
clientId: string
clientSecret: string
defaultScope: string
}
overrides— Partial configuration to merge over env defaults.
Returns: The fully-resolved configuration.
sanitizeError(error, secrets)
Strip client secrets and refresh tokens from an error before logging or rethrowing. Mutates and returns a new error-like object.
function sanitizeError(error: unknown, secrets: string[]): Error
error— The original error.secrets— Sensitive strings to redact.
Returns: A sanitized error suitable for logging or surfacing.
createMicrosoftProvider(overrides)
Build a Microsoft OAuth provider with optional config overrides.
function createMicrosoftProvider(overrides?: MicrosoftOAuthConfig): OAuthProvider
overrides— Optional configuration overrides.
Returns: A typed OAuthProvider.
getAuthorizeUrl(params)
Builds the Microsoft authorization URL for OAuth initiation
(GET /users/oauth/:provider 302s the browser here) per the core
OAuthAuthorizeUrlBuilder contract. Embeds this bond's client id,
default scopes (openid email profile User.Read — exactly what
verify's Microsoft Graph /me call needs), and the tenant-derived
authorize endpoint
(https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize,
tenant from OAUTH_MICROSOFT_TENANT_ID, default common), so no
consumer hardcodes Microsoft knowledge.
Includes the caller's CSRF state, response_mode=query (the v2.0
auth-code flow's query-string callback), and — when a codeChallenge
is supplied — the PKCE code_challenge + code_challenge_method
(default S256). PKCE with a confidential client is supported and
recommended by the Microsoft identity platform: the client secret and
code_verifier are complementary, and exchangeCodeForTokens MUST
then be given the matching verifier (see AADSTS501481).
function getAuthorizeUrl({
redirectUri,
state,
codeChallenge,
codeChallengeMethod,
}: OAuthAuthorizeUrlParams): string | null
params— State, PKCE challenge, and optional redirect URI.
Returns: The Microsoft authorize URL, or null when OAUTH_MICROSOFT_CLIENT_ID is unset (unconfigured).
verify(code, codeVerifier, redirectUri)
Verifies a Microsoft OAuth code and responds with normalized
OAuthUserProps for compatibility with the core @molecule/api-oauth
OAuthVerifier contract — exchanges the code (passing the PKCE
verifier through to the token endpoint), fetches /me from Microsoft
Graph, and shapes the result.
Returns null (never throws) when Microsoft AFFIRMATIVELY rejects the
code or verifier — the token endpoint's HTTP 400 invalid_grant
(AADSTS70008 expired/redeemed code, AADSTS501481 verifier mismatch) —
so the consumer responds 403 "verification failed". Infrastructure
failures (network, outage, malformed 2xx) still throw and surface as 500.
function verify(
code: string,
codeVerifier?: string,
redirectUri?: string,
): Promise<{
username: string
name: string | undefined
email: string | undefined
emailVerified: false
oauthServer: 'microsoft'
oauthId: string
oauthData: Record<string, unknown>
} | null>
code— The authorization code from the OAuth callback.codeVerifier— PKCE code verifier matching thecode_challengesent bygetAuthorizeUrl. Required by Microsoft at redemption whenever the authorization request carried a challenge.redirectUri— The redirect URI used in the authorization request. Falls back toAPP_ORIGIN.
Returns: Normalized OAuthUserProps, or null when the provider rejected the code.
Constants
serverName
The OAuth server identifier for Microsoft.
const serverName: 'microsoft'
DEFAULT_SCOPE
Default OAuth scopes when none are supplied.
const DEFAULT_SCOPE: 'openid email profile User.Read'
GRAPH_ME_URL
Microsoft Graph endpoint for the signed-in user.
const GRAPH_ME_URL: 'https://graph.microsoft.com/v1.0/me'
provider
Default Microsoft OAuth provider, configured from environment.
Use createMicrosoftProvider() with explicit config for tests or
multi-tenant apps that need per-request configuration.
const provider: OAuthProvider
oauthMicrosoftSecretDefinitions
Secret definitions required by the Microsoft OAuth bond.
const oauthMicrosoftSecretDefinitions: SecretDefinition[]
Core Interface
Implements @molecule/api-oauth interface.
Bond Wiring
Setup function to register this provider with the bond system:
import { bond } from '@molecule/api-bond'
import { serverName, verify, getAuthorizeUrl } from '@molecule/api-oauth-microsoft'
export function setupOauthMicrosoft(): void {
bond('oauth', serverName, { serverName, verify, getAuthorizeUrl })
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-http^1.0.1@molecule/api-oauth^1.0.1@molecule/api-secrets^1.0.1
Environment Variables
OAUTH_MICROSOFT_CLIENT_ID(required) — Microsoft application (client) ID- Setup: Microsoft Entra ID → App registrations → New registration; copy the Application (client) ID.
- Get it here: https://entra.microsoft.com/
OAUTH_MICROSOFT_CLIENT_SECRET(required) — Microsoft client secret- Setup: App registration → Certificates & secrets → New client secret; copy the Value.
- Get it here: https://entra.microsoft.com/
OAUTH_MICROSOFT_TENANT_ID(optional) — Microsoft directory (tenant) ID- Setup: Copy the Directory (tenant) ID from the app registration overview, or use "common" for multi-tenant.
- Get it here: https://entra.microsoft.com/
- Example:
common
Runtime Dependencies
@molecule/api-bond@molecule/api-http@molecule/api-oauth@molecule/api-secrets
ID-token issuer / tenant validation contract. verifyMicrosoftIdToken
(and provider.verifyIdToken) validate iss against the issuer(s) implied
by the configured tenant (OAUTH_MICROSOFT_TENANT_ID / config.tenantId)
— never against the token's own tid. This matters because Microsoft's
public-cloud signing keys are shared across every tenant, so a
validly-signed token issued for a different directory would otherwise
satisfy a single-tenant configuration (a cross-tenant authentication
bypass). The rule:
- Concrete tenant GUID configured (single-tenant pin): the accepted
issuer is fixed to that exact tenant, and the token's
tidmust equal the configured tenant. A token's self-assertedtidcan NOT widen the accepted issuer set. common/organizations/consumers(multi-tenant): any directory's users may sign in by design, so the token'stid-derivedhttps://login.microsoftonline.com/{tid}/v2.0issuer IS accepted — this is the documented multi-tenant contract, not a widening of a pin.
Pin to a single tenant by setting OAUTH_MICROSOFT_TENANT_ID to the
directory GUID; leave it common only when multi-tenant sign-in is
intended.
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Clicking the app's "Sign in with {provider}" button (Google, GitHub, …) redirects to the provider's authorize URL carrying the correct client_id, the app's requested scopes, AND the app's registered redirect_uri — inspect the actual outbound URL (the 302 Location, or the address the popup/tab navigates to) and confirm each value; a missing or wrong one is the bug.
- The callback route exchanges the returned code SERVER-SIDE for a token, fetches the profile, and creates-or-links the app user + establishes a session — after the round-trip the app shows that user logged in. CAVEAT: the provider's own consent screen runs on ITS domain and CANNOT be driven in the sandbox, so verify the two boundaries you DO own — the authorize URL going out (above) and the callback coming back — not the provider's page. Complete the round-trip with a test/stub provider bond if one is wired; otherwise assert the callback handler's own behavior (state check → code exchange → user create-or-link → session). Never mock the flow or edit production code to bypass the provider.
- A returning OAuth user logs into the SAME account — sign in twice and confirm one user row linked by provider id (oauthServer + oauthId), not a fresh duplicate created each time.
- SECURITY — the
stateparameter is generated on initiation and verified on callback (CSRF protection): a mismatched or absentstateis rejected (403); theredirect_uriis validated against an allowlist so an attacker cannot redirect the code elsewhere; and the client secret + tokens stay server-side — grep the browser bundle and network tab to confirm the secret never reaches the client (only the authorize URL and returned code cross the boundary).