@finnairoyj/cdk-constructs-lib - v0.2.3
    Preparing search index...

    OAuth 2.0 CloudFront Edge Lambda Handler

    A Lambda@Edge viewer-request handler that protects a CloudFront distribution by enforcing authentication via the OAuth 2.0 Authorization Code Flow with PKCE (RFC 7636). It is designed to work with any OpenID Connect (OIDC)-compliant identity provider and has been specifically tested with Microsoft Entra ID (Azure AD) v2.0.


    The handler intercepts every viewer request to the CloudFront distribution before it reaches the origin. Depending on the request URI and the presence of session cookies, it either passes the request through to the origin or performs one of the OAuth operations described below.

    %%{init:{"theme":"dark"}}%% sequenceDiagram actor Browser participant CF as CloudFront
    (Lambda@Edge) participant IDP as Identity Provider
    (Entra ID) participant Origin Note over Browser,Origin: 1 — First visit (no session) Browser->>CF: GET /protected-page CF-->>Browser: 302 → IDP /authorize
    (PKCE code_challenge, state, nonce)
    Set-Cookie: state, nonce, code_verifier (10 min) Browser->>IDP: GET /authorize (user logs in) IDP-->>Browser: 302 → /callback?code=…&state=… Browser->>CF: GET /callback?code=…&state=…
    Cookie: state, nonce, code_verifier CF->>IDP: POST /token (code + code_verifier) IDP-->>CF: access_token, refresh_token, id_token Note over CF: Validate state (CSRF) and nonce (replay) CF-->>Browser: 302 → /
    Set-Cookie: access_token, refresh_token
    Clear: state, nonce, code_verifier Note over Browser,Origin: 2 — Authenticated request (valid token) Browser->>CF: GET /protected-page
    Cookie: access_token, refresh_token Note over CF: Verify JWT (JWKS, audience, issuer, expiry) CF->>Origin: Forwarded request Origin-->>CF: Response CF-->>Browser: Response Note over Browser,Origin: 3 — Expired access token (refresh) Browser->>CF: GET /protected-page
    Cookie: access_token (expired), refresh_token CF->>IDP: POST /token (grant_type=refresh_token) IDP-->>CF: New access_token, refresh_token CF-->>Browser: 302 → /protected-page
    Set-Cookie: new access_token, refresh_token Note over Browser,Origin: 4 — Logout Browser->>CF: GET /logout
    Cookie: access_token Note over CF: Decode access_token → extract login_hint claim CF-->>Browser: 302 → IDP /logout?logout_hint=…
    Clear: access_token, refresh_token
    %%{init:{"theme":"default"}}%% sequenceDiagram actor Browser participant CF as CloudFront
    (Lambda@Edge) participant IDP as Identity Provider
    (Entra ID) participant Origin Note over Browser,Origin: 1 — First visit (no session) Browser->>CF: GET /protected-page CF-->>Browser: 302 → IDP /authorize
    (PKCE code_challenge, state, nonce)
    Set-Cookie: state, nonce, code_verifier (10 min) Browser->>IDP: GET /authorize (user logs in) IDP-->>Browser: 302 → /callback?code=…&state=… Browser->>CF: GET /callback?code=…&state=…
    Cookie: state, nonce, code_verifier CF->>IDP: POST /token (code + code_verifier) IDP-->>CF: access_token, refresh_token, id_token Note over CF: Validate state (CSRF) and nonce (replay) CF-->>Browser: 302 → /
    Set-Cookie: access_token, refresh_token
    Clear: state, nonce, code_verifier Note over Browser,Origin: 2 — Authenticated request (valid token) Browser->>CF: GET /protected-page
    Cookie: access_token, refresh_token Note over CF: Verify JWT (JWKS, audience, issuer, expiry) CF->>Origin: Forwarded request Origin-->>CF: Response CF-->>Browser: Response Note over Browser,Origin: 3 — Expired access token (refresh) Browser->>CF: GET /protected-page
    Cookie: access_token (expired), refresh_token CF->>IDP: POST /token (grant_type=refresh_token) IDP-->>CF: New access_token, refresh_token CF-->>Browser: 302 → /protected-page
    Set-Cookie: new access_token, refresh_token Note over Browser,Origin: 4 — Logout Browser->>CF: GET /logout
    Cookie: access_token Note over CF: Decode access_token → extract login_hint claim CF-->>Browser: 302 → IDP /logout?logout_hint=…
    Clear: access_token, refresh_token
    sequenceDiagram
        actor Browser
        participant CF as CloudFront<br/>(Lambda@Edge)
        participant IDP as Identity Provider<br/>(Entra ID)
        participant Origin
    
        Note over Browser,Origin: 1 — First visit (no session)
        Browser->>CF: GET /protected-page
        CF-->>Browser: 302 → IDP /authorize<br/>(PKCE code_challenge, state, nonce)<br/>Set-Cookie: state, nonce, code_verifier (10 min)
        Browser->>IDP: GET /authorize (user logs in)
        IDP-->>Browser: 302 → /callback?code=…&state=…
        Browser->>CF: GET /callback?code=…&state=…<br/>Cookie: state, nonce, code_verifier
        CF->>IDP: POST /token (code + code_verifier)
        IDP-->>CF: access_token, refresh_token, id_token
        Note over CF: Validate state (CSRF) and nonce (replay)
        CF-->>Browser: 302 → /<br/>Set-Cookie: access_token, refresh_token<br/>Clear: state, nonce, code_verifier
    
        Note over Browser,Origin: 2 — Authenticated request (valid token)
        Browser->>CF: GET /protected-page<br/>Cookie: access_token, refresh_token
        Note over CF: Verify JWT (JWKS, audience, issuer, expiry)
        CF->>Origin: Forwarded request
        Origin-->>CF: Response
        CF-->>Browser: Response
    
        Note over Browser,Origin: 3 — Expired access token (refresh)
        Browser->>CF: GET /protected-page<br/>Cookie: access_token (expired), refresh_token
        CF->>IDP: POST /token (grant_type=refresh_token)
        IDP-->>CF: New access_token, refresh_token
        CF-->>Browser: 302 → /protected-page<br/>Set-Cookie: new access_token, refresh_token
    
        Note over Browser,Origin: 4 — Logout
        Browser->>CF: GET /logout<br/>Cookie: access_token
        Note over CF: Decode access_token → extract login_hint claim
        CF-->>Browser: 302 → IDP /logout?logout_hint=…<br/>Clear: access_token, refresh_token
    Condition Action
    GET /callback?error=… IDP returned an error — restart the login flow (redirect to IDP)
    GET /callback?code=…&state=… + valid session cookies Exchange the authorization code, validate the OIDC nonce, set session cookies, redirect to /
    GET /callback — invalid / missing params or cookies Redirect to auth error page
    GET /logout Decode the access_token cookie to extract login_hint, redirect to the IDP end_session_endpoint with logout_hint (if available) and post_logout_redirect_uri, clear all cookies
    URI matches a configured public prefix Pass through without authentication (only evaluated when publicPathPrefixes is configured)
    Request has valid access_token cookie Pass through to origin
    Request has expired access_token + refresh_token Exchange the refresh token for new tokens. Navigation requests are redirected to the original URI with new cookies; API requests (Accept: application/json) receive HTTP 401 with new tokens in Set-Cookie
    Refresh token exchange fails Redirect to IDP (restart login)
    Invalid JWT (JWTClaimValidationFailed, JWTInvalid) Redirect to IDP
    Any other unhandled error Attempt clean logout via IDP, fall back to auth error page
    No session cookies Redirect to IDP (start login)
    1. Unauthenticated request arrives → handler generates a random state, nonce, and a PKCE code_verifier/code_challenge pair.
    2. The browser is redirected to the IDP's authorization_endpoint with the PKCE and OIDC parameters.
    3. state, nonce, and code_verifier are stored as short-lived (10 min) HttpOnly; Secure; SameSite=Lax cookies.
    4. The IDP authenticates the user and redirects back to https://{appDomainName}/callback?code=…&state=….
    5. The handler validates the state against the cookie (CSRF protection), exchanges the code at the token_endpoint, and verifies the nonce inside the returned id_token (replay protection).
    6. On success, access_token and refresh_token cookies are set and the user is redirected to /.

    Access tokens are verified on every authenticated request using the IDP's published JWKS (jwks_uri from the OIDC discovery document). Verification checks:

    • Signature (RS256)
    • Audience: clientId plus any values in opts.audience
    • Issuer: issuer from the OIDC config plus any values in opts.issuer
    • Expiry

    The id_token is verified only at callback time to validate the nonce claim (replay protection). Its issuer and audience are always validated strictly against the values from the OIDC discovery document (config.issuer) and clientId respectively — the opts.issuer and opts.audience extensions do not apply to id_token validation. This is intentional: id_token claims are defined by the OIDC spec and should not vary, whereas access_token claims differ between providers and token versions (see Microsoft Entra ID notes below).

    The OIDC well-known config and the JWKS client are cached in memory (default TTL: 60 minutes) to minimise latency on the Lambda@Edge 5-second budget. The parsed configuration options are also cached to avoid re-parsing the build-time-injected string literals on every invocation.


    Because Lambda@Edge does not support environment variables, all configuration values are injected at build time by ESBuild, which replaces #PLACEHOLDER# tokens in the source with real values. In practice you pass the values to the CDK construct that builds and deploys the Lambda, and the construct handles the substitution.

    Option Type Required Description
    appDomainName string The public domain name of the CloudFront distribution (e.g. app.example.com). Used to construct redirect URIs and set cookie Domain.
    clientId string The OAuth 2.0 / Entra ID application (client) ID. Validated as the aud claim in both access_token and id_token by default.
    wellKnownUri string URL of the OIDC discovery document. For Entra ID: https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration
    scopes string[] OAuth scopes to request. Must include openid for OIDC. Include offline_access to receive a refresh token. Example: ['openid', 'profile', 'offline_access']
    sessionValidity number Max-Age in seconds for the refresh_token cookie. Controls the maximum session duration. The access_token cookie uses the expires_in value returned by the IDP.
    publicPathPrefixes string[] URI path prefixes that bypass authentication entirely. Use for static error pages or health-check endpoints. Example: ['/public/']. Omit or set to undefined to require auth for all paths.
    logoutRedirectUri string Full URL to redirect to after IDP logout (e.g. https://app.example.com/public/logout.html). Sent directly to the IDP as post_logout_redirect_uri. The URL must be registered with the IDP. Omit or set to '' to skip the redirect and let the IDP show its own logout page.
    authErrorPageUri string Full URL of the page to redirect to when an unrecoverable authentication error occurs (e.g. https://app.example.com/public/auth-error.html). The page must be listed in publicPathPrefixes and registered as an allowed redirect URI with the IDP. When omitted, the handler falls back to redirecting to https://{appDomainName}/.
    issuer string[] Additional iss claim values accepted during access_token validation only. The id_token issuer is always validated strictly against the OIDC discovery document value. Required for Entra ID when the app manifest's accessTokenAcceptedVersion is null or 1 — in that case the access token issuer is https://sts.windows.net/{tenantId}/ rather than the v2 OIDC issuer. Example: ['https://sts.windows.net/{tenantId}/']
    audience string[] Additional aud claim values accepted during access_token validation only. The id_token audience is always validated strictly as clientId. Use when the IDP issues access tokens scoped to a resource URI rather than the client ID. Example: ['api://{resourceId}']
    domainHint string Passed as domain_hint to the IDP's authorization_endpoint. Bypasses the Home Realm Discovery (HRD) email prompt for users who have multiple active logins. Useful for Entra ID tenants. Example: 'contoso.com'

    The following URIs must be registered as Redirect URIs in the IDP application registration:

    • https://{appDomainName}/callback — authorization code callback
    • {authErrorPageUri} — auth error page (if configured)
    • {logoutRedirectUri} — post-logout redirect (if configured)

    Entra ID issues two structurally different access_token formats depending on the app registration configuration:

    accessTokenAcceptedVersion in app manifest Access token iss claim opts.issuer required?
    null or 1 (default) https://sts.windows.net/{tenantId}/ Yes
    2 https://login.microsoftonline.com/{tenantId}/v2.0 No (matches OIDC discovery)

    When using v1 access tokens (the default for most tenants), you must set opts.issuer to the STS issuer URL, otherwise every access_token verification call will fail with an issuer mismatch.

    The id_token issuer is always https://login.microsoftonline.com/{tenantId}/v2.0 regardless of the access token version, and is validated strictly against the OIDC discovery document — opts.issuer has no effect on id_token validation.

    At /logout, the handler decodes the access_token cookie (without re-verifying the signature, as the token is about to be discarded) and reads the login_hint claim. If present, it is passed to the IDP's end_session_endpoint as logout_hint. For Entra ID this suppresses the "Are you sure you want to sign out?" confirmation page and the Home Realm Discovery prompt, providing a seamless logout experience.

    The login_hint claim is the preferred_username (UPN/email) of the authenticated user. Entra ID includes it in access_tokens by default when the v2 token endpoint is used. If the claim is absent (e.g. guest accounts, service principals, or v1 tokens), the IDP logout page is shown without a hint — the logout itself still succeeds.


    Cookie Lifetime Purpose
    access_token expires_in from token response Carries the bearer token; verified on every request
    refresh_token sessionValidity (config) Used to silently renew the access token when it expires
    code_verifier 10 minutes PKCE verifier, set during login, cleared after callback
    state 10 minutes CSRF token, set during login, cleared after callback
    nonce 10 minutes Replay-protection value embedded in id_token, cleared after callback

    All cookies are set with Secure; HttpOnly; SameSite=Lax; Domain={appDomainName}.


    The handler uses a layered fallback strategy to ensure the user always receives a usable response even when the IDP is unreachable:

    1. Known auth errors (state mismatch, nonce mismatch, token exchange failure) → redirect to authErrorPageUri.
    2. Unexpected errors during token verification → attempt a clean IDP logout with authErrorPageUri as the post-logout redirect, so the IDP clears its own session.
    3. IDP logout itself fails (e.g. network issue) → fall back directly to getRedirectToAuthErrorPage, which clears all local cookies.
    4. Top-level unhandled errors (e.g. OIDC config fetch fails on startup) → same two-step fallback as above.

    All fetch calls use an AbortController with a timeout derived from the Lambda's remaining execution time minus a 500 ms grace period, preventing the handler from hanging until the Lambda hard timeout.