@finnairoyj/cdk-constructs-lib - v0.1.11
    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 CF-->>Browser: 302 → IDP /logout
    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 CF-->>Browser: 302 → IDP /logout
    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
        CF-->>Browser: 302 → IDP /logout<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 Redirect to the IDP end_session_endpoint, clear all cookies
    URI matches a configured public prefix Pass through without authentication
    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)
    • Issuer (issuer from the OIDC config)
    • Expiry

    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 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. Used as the audience claim when validating access tokens.
    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']
    publicUriPrefixes string[] List of URI path prefixes that bypass authentication entirely. Use for static error pages or health-check endpoints. Example: ['/public/']. Set to [] to require auth for all paths.
    logoutRedirectUri string Path to redirect to after IDP logout (e.g. /public/logout.html). The full URL https://{appDomainName}{logoutRedirectUri} is sent to the IDP as post_logout_redirect_uri. Set to '' to skip the redirect and let the IDP show its own logout page.
    authErrorPageUri string Path of the page to redirect to when an unrecoverable authentication error occurs (e.g. /public/auth-error.html). The page must be listed in publicUriPrefixes and must be registered as an allowed redirect URI with the IDP. Set to '' to redirect to the distribution root instead.
    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.

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

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

    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.