@finnairoyj/cdk-constructs-lib - v1.0.2
    Preparing search index...

    OAuth 2.0 API Gateway Authorizer Handler

    A Lambda TOKEN authorizer handler that protects an Amazon API Gateway REST API by validating the OAuth 2.0 / OIDC access_token presented in the request. It works with any OpenID Connect (OIDC)-compliant identity provider and has been specifically tested with Microsoft Entra ID (Azure AD) v2.0.

    Unlike the CloudFront edge handler, this handler performs bearer-token validation only — it does not implement the login flow, PKCE, cookies, or token refresh. It assumes the caller already holds a valid access_token (obtained out of band) and passes it in the Authorization header. The handler returns an IAM policy that API Gateway uses to allow or deny the invocation.


    API Gateway invokes the authorizer with the value of the request's Authorization header (the authorizationToken) and the methodArn being invoked. The handler verifies the token and returns an Allow or Deny IAM policy.

    %%{init:{"theme":"dark"}}%% sequenceDiagram actor Client participant APIGW as API Gateway participant Auth as Authorizer Lambda participant IDP as Identity Provider
    (Entra ID) participant Backend Note over Client,Backend: 1 — First request with a token (cold authorizer) Client->>APIGW: GET /resource
    Authorization: Bearer APIGW->>Auth: authorizationToken + methodArn Auth->>IDP: GET /.well-known/openid-configuration IDP-->>Auth: token_endpoint, jwks_uri, issuer Auth->>IDP: GET jwks_uri (public keys) IDP-->>Auth: JWKS Note over Auth: Verify JWT (signature, aud, iss, expiry) Auth-->>APIGW: Allow policy (stage-wide wildcard) APIGW->>Backend: Forwarded request Backend-->>Client: Response Note over Client,Backend: 2 — Subsequent request (warm authorizer, cached policy) Client->>APIGW: GET /other-resource
    Authorization: Bearer Note over APIGW: Cached authorizer result reused
    (policy covers the whole stage) APIGW->>Backend: Forwarded request Backend-->>Client: Response Note over Client,Backend: 3 — Invalid or missing token Client->>APIGW: GET /resource
    Authorization: APIGW->>Auth: authorizationToken + methodArn Note over Auth: Verification fails Auth-->>APIGW: Deny policy (context.message = reason) APIGW-->>Client: 403 Forbidden
    %%{init:{"theme":"default"}}%% sequenceDiagram actor Client participant APIGW as API Gateway participant Auth as Authorizer Lambda participant IDP as Identity Provider
    (Entra ID) participant Backend Note over Client,Backend: 1 — First request with a token (cold authorizer) Client->>APIGW: GET /resource
    Authorization: Bearer APIGW->>Auth: authorizationToken + methodArn Auth->>IDP: GET /.well-known/openid-configuration IDP-->>Auth: token_endpoint, jwks_uri, issuer Auth->>IDP: GET jwks_uri (public keys) IDP-->>Auth: JWKS Note over Auth: Verify JWT (signature, aud, iss, expiry) Auth-->>APIGW: Allow policy (stage-wide wildcard) APIGW->>Backend: Forwarded request Backend-->>Client: Response Note over Client,Backend: 2 — Subsequent request (warm authorizer, cached policy) Client->>APIGW: GET /other-resource
    Authorization: Bearer Note over APIGW: Cached authorizer result reused
    (policy covers the whole stage) APIGW->>Backend: Forwarded request Backend-->>Client: Response Note over Client,Backend: 3 — Invalid or missing token Client->>APIGW: GET /resource
    Authorization: APIGW->>Auth: authorizationToken + methodArn Note over Auth: Verification fails Auth-->>APIGW: Deny policy (context.message = reason) APIGW-->>Client: 403 Forbidden
    sequenceDiagram
        actor Client
        participant APIGW as API Gateway
        participant Auth as Authorizer Lambda
        participant IDP as Identity Provider<br/>(Entra ID)
        participant Backend
    
        Note over Client,Backend: 1 — First request with a token (cold authorizer)
        Client->>APIGW: GET /resource<br/>Authorization: Bearer <access_token>
        APIGW->>Auth: authorizationToken + methodArn
        Auth->>IDP: GET /.well-known/openid-configuration
        IDP-->>Auth: token_endpoint, jwks_uri, issuer
        Auth->>IDP: GET jwks_uri (public keys)
        IDP-->>Auth: JWKS
        Note over Auth: Verify JWT (signature, aud, iss, expiry)
        Auth-->>APIGW: Allow policy (stage-wide wildcard)
        APIGW->>Backend: Forwarded request
        Backend-->>Client: Response
    
        Note over Client,Backend: 2 — Subsequent request (warm authorizer, cached policy)
        Client->>APIGW: GET /other-resource<br/>Authorization: Bearer <same access_token>
        Note over APIGW: Cached authorizer result reused<br/>(policy covers the whole stage)
        APIGW->>Backend: Forwarded request
        Backend-->>Client: Response
    
        Note over Client,Backend: 3 — Invalid or missing token
        Client->>APIGW: GET /resource<br/>Authorization: <invalid / none>
        APIGW->>Auth: authorizationToken + methodArn
        Note over Auth: Verification fails
        Auth-->>APIGW: Deny policy (context.message = reason)
        APIGW-->>Client: 403 Forbidden

    The authorizationToken is taken verbatim from the request's Authorization header. A leading Bearer prefix is stripped if present; otherwise the whole header value is treated as the token. If no token is present, the handler returns a Deny policy with the message No token provided.

    The access_token is verified against the IDP's published JWKS (jwks_uri from the OIDC discovery document). Verification checks:

    • Signature (RS256)
    • Audience: clientId plus any values in audience
    • Issuer: issuer from the OIDC config plus any values in the issuer option
    • Expiry (standard JWT exp validation)

    On success the handler builds an Allow policy whose principalId is the token's upn claim. On failure it returns a Deny policy whose context.message carries the verification error message.

    API Gateway caches a TOKEN authorizer's result per token, and reuses it across every method and resource path invoked with the same token. If the returned policy were scoped to the single methodArn that triggered the authorizer, the cached policy would be rejected on any other method invoked with the same token.

    To avoid this, a successful policy is scoped to a stage-wide wildcard:

    arn:aws:execute-api:{region}:{account}:{apiId}/{stage}/{method}/{path}
    ↓
    arn:aws:execute-api:{region}:{account}:{apiId}/{stage}/*/*

    A Deny policy is left scoped to the original methodArn — denials are not meant to be cached and reused across the stage.

    The handler keeps an in-memory cache (default TTL: 60 minutes, see utils/cache.ts) across warm invocations for:

    • The parsed configuration options (avoids re-parsing the injected environment strings).
    • The OIDC well-known configuration document.
    • The JWKS client (which itself caches the signing keys with a timeout derived from the Lambda's remaining execution time).

    This minimises IDP round-trips: only the first invocation on a cold container fetches the discovery document and keys.


    Configuration is passed to the CDK construct and injected into the Lambda as environment variables. The handler reads them via getOpts():

    Environment variable Source option Description
    CLIENT_ID clientId OAuth 2.0 client ID; always an accepted aud claim value.
    WELL_KNOWN_URI wellKnownUri OIDC discovery document URL.
    IDP_AUDIENCE audience JSON array of additional accepted aud claim values.
    IDP_ISSUER issuer JSON array of additional accepted iss claim values.
    Option Type Required Description
    clientId string ✓ The OAuth 2.0 / Entra ID application (client) ID. Validated as the aud claim by default. For Entra ID this is the Enterprise App ID of the OIDC configuration.
    wellKnownUri string ✓ URL of the OIDC discovery document. For Entra ID: https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration
    audience string[] — Additional aud claim values accepted during access_token validation. Extends the default — clientId is always accepted. Use when the IDP issues access tokens scoped to a resource URI rather than the client ID. Example: ['api://{resourceId}']
    issuer string[] — Additional iss claim values, merged with the issuer read from the well-known endpoint. Required for Entra ID when the app manifest's accessTokenAcceptedVersion is null or 1 — the access token issuer is then https://sts.windows.net/{tenantId}/ rather than the v2 issuer.

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

    accessTokenAcceptedVersion in app manifest Access token iss claim issuer option 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 the issuer option to the STS issuer URL, otherwise every verification call will fail with an issuer mismatch.

    The principalId of a successful policy is taken from the token's upn (User Principal Name) claim. Entra ID includes it in access_tokens for user identities; it may be absent for guest accounts, service principals, or v1 tokens, in which case the principalId is an empty string but the request is still allowed.


    • No token → Deny policy, context.message = 'No token provided'.
    • Verification failure (bad signature, wrong audience/issuer, expired, malformed) → Deny policy scoped to the methodArn, with the error message in context.message. API Gateway translates this into a 403 Forbidden.
    • IDP unreachable / discovery fetch timeout → the verification promise rejects and is handled as a verification failure (Deny).

    All fetch calls (OIDC discovery and JWKS retrieval) use an AbortController with a timeout derived from the Lambda's remaining execution time minus a 500 ms grace period (TIMEOUT_GRACE), so the handler fails gracefully rather than hanging until the Lambda hard timeout.

    Logging: the handler logs the raw event and config only at DEBUG level, which exposes the access token. Keep the log level at INFO (the default) outside of test environments.