{"_id":"@cruglobal/cru-iap","_rev":"3-0aa8ac1fb905b6eead672b42227a35e8","name":"@cruglobal/cru-iap","dist-tags":{"latest":"0.2.1"},"versions":{"0.2.0":{"name":"@cruglobal/cru-iap","version":"0.2.0","license":"MIT","_id":"@cruglobal/cru-iap@0.2.0","maintainers":[{"name":"twinge","email":"josh.starcher@cru.org"},{"name":"johnplastow","email":"john.plastow@cru.org"},{"name":"jcwatson11","email":"jon@sherlockwatson.com"},{"name":"canac-cru","email":"caleb.cox@cru.org"},{"name":"frett","email":"daniel.frett@gmail.com"},{"name":"daniel.bizz","email":"daniel@bizz-websites.com"},{"name":"omicron7","email":"brian.zoetewey@cru.org"},{"name":"rwguinee","email":"ryan.guinee@cru.org"},{"name":"wjames1111","email":"william.james@cru.org"}],"homepage":"https://github.com/CruGlobal/cru-iap#readme","bugs":{"url":"https://github.com/CruGlobal/cru-iap/issues"},"dist":{"shasum":"8ab1a73ac00327966dce014a2a69d5e5e267d797","tarball":"https://registry.npmjs.org/@cruglobal/cru-iap/-/cru-iap-0.2.0.tgz","fileCount":44,"integrity":"sha512-YRglEB9ZNcpuKLG1qOAysfgF3cwFc/ewQUfYsiVY/H9WZpNZrbZrN4WcL82K3D59u15moUbaujDiXUkzZyIApg==","signatures":[{"sig":"MEQCIBcYnp+GhQGSngF0wfIZ+/48/KenYxvIdV1Ic1PHBSPeAiBGAlGIeC81M05gcGlgThvyBgNgG2RK4vq3PDU6ZZ7pqA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":167210},"type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./next":{"types":"./dist/next.d.ts","default":"./dist/next.js"}},"gitHead":"eb4985caea52533a74962dfa16dc9eae3d3eb146","scripts":{"test":"vitest run --project unit","build":"tsc -p tsconfig.build.json","prepare":"npm run build","test:all":"vitest run","test:e2e":"vitest run --project e2e","typecheck":"tsc -p tsconfig.json --noEmit","test:watch":"vitest"},"_npmUser":{"name":"omicron7","email":"brian.zoetewey@cru.org"},"repository":{"url":"git+https://github.com/CruGlobal/cru-iap.git","type":"git"},"_npmVersion":"11.12.1","description":"Verify Google Identity-Aware Proxy assertion JWTs. TypeScript sibling of the cru_iap Ruby gem.","directories":{},"_nodeVersion":"24.15.0","dependencies":{"jose":"^6.2.0"},"_hasShrinkwrap":false,"devDependencies":{"next":"^16.2.12","vitest":"^4.1.10","typescript":"^5.9.3","@types/node":"^24.13.3"},"peerDependencies":{"next":">=15.3.0"},"peerDependenciesMeta":{"next":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cru-iap_0.2.0_1785957677864_0.8208510966829861","host":"s3://npm-registry-packages-npm-production"}},"0.2.1":{"name":"@cruglobal/cru-iap","version":"0.2.1","license":"MIT","_id":"@cruglobal/cru-iap@0.2.1","maintainers":[{"name":"twinge","email":"josh.starcher@cru.org"},{"name":"johnplastow","email":"john.plastow@cru.org"},{"name":"jcwatson11","email":"jon@sherlockwatson.com"},{"name":"canac-cru","email":"caleb.cox@cru.org"},{"name":"frett","email":"daniel.frett@gmail.com"},{"name":"daniel.bizz","email":"daniel@bizz-websites.com"},{"name":"omicron7","email":"brian.zoetewey@cru.org"},{"name":"rwguinee","email":"ryan.guinee@cru.org"},{"name":"wjames1111","email":"william.james@cru.org"}],"homepage":"https://github.com/CruGlobal/cru-iap#readme","bugs":{"url":"https://github.com/CruGlobal/cru-iap/issues"},"dist":{"shasum":"7a5c711202d55902e8062458323383a848d154c9","tarball":"https://registry.npmjs.org/@cruglobal/cru-iap/-/cru-iap-0.2.1.tgz","fileCount":44,"integrity":"sha512-rl7JtrMkuIWXi1cwjDJC/Ysd2aQQGNuDGjzYUQLlpowi01oVYfQLwuH3iJ9tYTqfQoDOAZe2eAq7B2HCl26Tog==","signatures":[{"sig":"MEUCICM6EdTx8+EVE01jEHgAAQQ8vHg7An2ZZsE0YQB1oUc8AiEA7TiceoUvovjW4Rn9gxsGWBQUegDvYJtEwy2FH4D4A5Q=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@cruglobal%2fcru-iap@0.2.1","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":168834},"type":"module","types":"./dist/index.d.ts","engines":{"node":">=20"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./next":{"types":"./dist/next.d.ts","default":"./dist/next.js"}},"gitHead":"8f7850850495092e31ca9871410da2678709182a","scripts":{"test":"vitest run --project unit","build":"tsc -p tsconfig.build.json","prepare":"npm run build","test:all":"vitest run","test:e2e":"vitest run --project e2e","typecheck":"tsc -p tsconfig.json --noEmit","test:watch":"vitest"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:0205e61a-8b60-4931-b299-660c3f31acc5"}},"repository":{"url":"git+https://github.com/CruGlobal/cru-iap.git","type":"git"},"_npmVersion":"11.16.0","description":"Verify Google Identity-Aware Proxy assertion JWTs. TypeScript sibling of the cru_iap Ruby gem.","directories":{},"_nodeVersion":"24.18.0","dependencies":{"jose":"^6.2.0"},"_hasShrinkwrap":false,"devDependencies":{"next":"^16.2.12","vitest":"^4.1.10","typescript":"^5.9.3","@types/node":"^24.13.3"},"peerDependencies":{"next":">=15.3.0"},"peerDependenciesMeta":{"next":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cru-iap_0.2.1_1785965698315_0.6075211703309298","host":"s3://npm-registry-packages-npm-production"}}},"time":{"created":"2026-08-05T19:21:17.732Z","modified":"2026-08-25T17:24:23.025Z","0.2.0":"2026-08-05T19:21:18.049Z","0.2.1":"2026-08-05T21:34:58.485Z"},"bugs":{"url":"https://github.com/CruGlobal/cru-iap/issues"},"license":"MIT","homepage":"https://github.com/CruGlobal/cru-iap#readme","repository":{"url":"git+https://github.com/CruGlobal/cru-iap.git","type":"git"},"description":"Verify Google Identity-Aware Proxy assertion JWTs. TypeScript sibling of the cru_iap Ruby gem.","maintainers":[{"email":"josh.starcher@cru.org","name":"twinge"},{"email":"jon@sherlockwatson.com","name":"jcwatson11"},{"email":"caleb.cox@cru.org","name":"canac-cru"},{"email":"daniel.frett@gmail.com","name":"frett"},{"email":"daniel@bizz-websites.com","name":"daniel.bizz"},{"email":"brian.zoetewey@cru.org","name":"omicron7"},{"email":"ryan.guinee@cru.org","name":"rwguinee"},{"email":"william.james@cru.org","name":"wjames1111"}],"readme":"# cru-iap\n\nRequest authentication for applications behind **Google Identity-Aware Proxy**, including\nIAP fronted by an external identity provider via **Workforce Identity Federation**.\n\nThis library verifies the IAP assertion JWT on an incoming request and returns either an\nemail identity or a typed rejection reason. It is built for Cru's internal applications,\nbut nothing in it is Cru-specific.\n\n**Four libraries, one repository.** The applications behind IAP are Rails, Next.js,\nFastAPI and Go, and the claim-shape knowledge documented below was expensive enough to\nlearn that maintaining four divergent copies of it would be a mistake.\n\n```ruby\ngem \"cru_iap\", github: \"CruGlobal/cru-iap\"\n```\n```sh\nnpm install @cruglobal/cru-iap\nuv add cru-iap\ngo get github.com/CruGlobal/cru-iap/cruiap\n```\n\n| | Ruby | TypeScript | Python | Go |\n|---|---|---|---|---|\n| Source | `lib/` | `src/` | `cru_iap/` | `cruiap/` |\n| Tests | `spec/` | `test/` | `tests/` | `cruiap/*_test.go` |\n| Runtime dependency | `googleauth` | `jose` | `pyjwt[crypto]` | **none** (stdlib) |\n| Entry point | `CruIap::TokenVerifier.from_request` | `verifyRequest` | `verify_request` | `VerifyRequest` |\n\nThe npm package and the Python package are published to their registries; the gem is\ninstalled from git and the Go package needs no registry at all, since `go get` resolves\nthe version straight from the tag. Every language's manifest sits at the repository root,\nso a bare-repository-URL install still works for all four — which is how you install an\nunreleased commit:\n\n```sh\nnpm install github:CruGlobal/cru-iap\nuv add \"cru-iap @ git+https://github.com/CruGlobal/cru-iap\"\n```\n\nThe gem name is underscored while the repository is hyphenated, so `Bundler.require`\nresolves straight to `lib/cru_iap.rb`; the npm package builds on install via `prepare`,\nwhich is what makes a git install work without a registry.\n\nAll four read the same pinned capture of a real Google assertion\n(`spec/fixtures/real_wif_iap_payload.json`), so they cannot quietly drift apart about\nwhat IAP actually sends. The rejection vocabulary is cross-checked mechanically too —\nsee [Rejection reasons](#rejection-reasons).\n\n## Scope\n\nThe library stops just past *\"who is this?\"*. It does not own your user model, your\nsession, your controller concern, how you render a rejection, or authorization.\n\nTwo things are in scope beyond verification, as pure functions with no framework\ncoupling: the [two IAP control URLs](#the-two-iap-control-urls) and the\n[dev bypass](#the-dev-bypass). Both are cases where getting it wrong is silent and\nsecurity-relevant, and where every consumer was otherwise re-deriving the same answer.\n\nThe one framework-coupled surface is the same case: three Next.js apps hand-rolled the\nsame middleware gate, and one of them ordered a header strip wrongly in a way that\nbypassed authentication outright. It lives behind the separate\n[`@cruglobal/cru-iap/next`](#nextjs-middleware--cruglobalcru-iapnext) entry point, so\n`next` stays an optional peer.\n\nThere is no Rails or ActiveSupport dependency. The TypeScript package touches no `node:`\nbuiltin, so it runs on the Edge runtime. The Python package imports no web framework,\nwhich a test enforces in a subprocess so its own imports cannot mask a leak.\n\n## Configuration\n\n`IAP_AUDIENCE` is read from the environment by default, at call time rather than at\nimport time, in all four languages. It is a **resource path** — not a URL and not a\nclient ID — and its shape depends on how IAP is fronted:\n\n| IAP mode | `aud` |\n|---|---|\n| Behind an external HTTPS load balancer | `/projects/NUMBER/global/backendServices/BACKEND_ID` |\n| Directly on Cloud Run (no load balancer) | `/projects/NUMBER/locations/REGION/services/SERVICE_NAME` |\n\nBoth are confirmed against live services. The verifier does not care which — it is an\nexact string compare — but a deploy that hardcodes the wrong *shape* fails with\n`audience_mismatch`, which reads like a config typo rather than an architecture\nmismatch.\n\nIf your Terraform sets this for you, do not rename it with an application prefix.\n\n**IAP directly on Cloud Run** is worth knowing about: it needs no load balancer, no\ncertificate and no DNS, which takes a test or low-traffic environment from roughly\n\\$18/month — the load balancer forwarding-rule bundle, billed regardless of traffic —\nto effectively zero. Set `run.googleapis.com/iap-enabled: 'true'` on the service and\npoint `iapSettings` at your workforce pool.\n\n## Usage (Ruby)\n\n```ruby\n# config/initializers/iap.rb\nCruIap.logger = Rails.logger\n```\n\n```ruby\n# config/application.rb\nconfig.middleware.insert_before 0, CruIap::StripForwardedHost\n```\n\n```ruby\nresult = CruIap::TokenVerifier.from_request(request)\n\nif result.ok?\n  user = User.from_iap(email: result.email, name: result.name)\nelse\n  Rails.logger.warn(\"IAP auth rejected: #{result.reason}\")\n  nil # fail closed — never fall through to a dev stub\nend\n```\n\n`from_request` accepts an `ActionDispatch::Request`, a `Rack::Request`, or a bare Rack\nenv hash, and pulls the assertion off it — application code never names the header. If\nyou need the wire name for infrastructure config or a test fixture, it is\n`CruIap::TokenVerifier::HEADER`.\n\nBoth `from_request` and the lower-level `.call(token)` accept `audience:` and `logger:`\noverrides.\n\n### Reference wiring (Rails controller)\n\n```ruby\n# Resolve once per request: the failure path must not re-verify the JWT and\n# re-log the rejection at every current_user call site.\ndef current_user\n  return @identity.user if defined?(@identity)\n  @identity = resolve_identity\n  @identity.user\nend\n\ndef resolve_identity\n  # Only trust the header on a host IAP actually fronts. On any other host —\n  # a second backend, or a direct *.run.app hit — there is no IAP in front to\n  # strip a client-supplied header, so ignore it rather than verify it.\n  if iap_fronted_host? && CruIap::TokenVerifier.assertion_from(request)\n    return Identity.new(user: user_from_iap(request), dev_stub: false)\n  end\n  return Identity.new(user: DevAuthStub.user, dev_stub: true) if Rails.env.local?\n\n  Identity.new(user: nil, dev_stub: false)\nend\n```\n\n## Usage (TypeScript)\n\n```ts\nimport { verifyRequest } from \"@cruglobal/cru-iap\";\n\nconst result = await verifyRequest(request);\n\nif (result.ok) {\n  const user = await provisionUser({ email: result.email, name: result.name });\n} else {\n  console.warn(`IAP auth rejected: ${result.reason}`);\n  // fail closed — never fall through to a dev stub\n}\n```\n\n`result` is a discriminated union, so `result.email` narrows to `string` inside the `ok`\nbranch and `null` outside it. `verifyRequest` accepts a Web `Request` or `Headers`\n(route handlers, middleware, Edge), a Node `IncomingMessage` (Express, a custom server),\nor a plain header record. `verify(token)` is the lower-level form. Both take\n`{ audience, logger, jwks, clockToleranceSeconds }`.\n\n### Next.js middleware — `@cruglobal/cru-iap/next`\n\nMiddleware is the right seam: one gate for every route, running before any page or route\nhandler allocates work for a request that is about to be rejected. Three apps wrote that\ngate by hand and got three different answers, so it ships as a factory:\n\n```ts\n// middleware.ts (or proxy.ts on Next 16 — the same function serves as either)\nimport { createIapProxy } from \"@cruglobal/cru-iap/next\";\n\n// App-owned, and it has to be: Next requires the matcher to be a statically\n// analyzable literal in this file.\nexport const config = { matcher: [\"/((?!_next/static|_next/image|favicon.ico).*)\"] };\n\nexport default createIapProxy({\n  // Exact-or-prefix match. Note the trailing slash on a directory prefix:\n  // \"/api/\" leaves /apiary gated, where a bare \"/api\" would not.\n  publicPrefixes: [\"/health\", \"/api/webhooks/\"],\n});\n```\n\nThe gate **fails closed**: no assertion and no `CRU_IAP_DEV_BYPASS_EMAIL` is a 401, in\nevery environment. The two shapes that reach for a shortcut here — opening the gate when\n`IAP_AUDIENCE` is unset, and a boolean bypass flag — are exactly the incidents this\npackage exists to prevent. Locally you get an identity, never an open gate:\n\n```sh\nCRU_IAP_DEV_BYPASS_EMAIL=you@cru.org npm run dev\n```\n\nOptions: `publicPrefixes`, `audience`, `logger`, `env`. A rejection logs one line of\n`{\"severity\":\"WARNING\",\"message\":\"iap_rejected\",\"reason\":…,\"path\":…}` and answers 401 —\nnever a redirect, because IAP owns sign-in and has already run, so bouncing the browser\nonly loops.\n\n**Anything in `publicPrefixes` (or excluded by the matcher) must ALSO be in the\nmodule's `iap.bypass_paths`.** Otherwise the load balancer 401s the request before your\napp ever runs, and the exemption is invisible. See\n[bypass_paths](#surfaces-that-authenticate-themselves-need-bypass_paths).\n\n### The identity headers\n\nThe gate stamps the verified identity onto the request it forwards, so downstream code\nreads it instead of re-verifying per route:\n\n| Header | |\n| --- | --- |\n| `x-cru-iap-email` | the identity; always present |\n| `x-cru-iap-name` | display name; absent when the assertion carries no `name` claim |\n| `x-cru-iap-issued-at` | the assertion's `iat`, decimal; absent under the dev bypass |\n\n```ts\nimport { identityFrom } from \"@cruglobal/cru-iap\";\n\nconst identity = identityFrom(await headers()); // { email, name, issuedAt } | null\n```\n\nWhat makes these trustworthy is not the names: it is that the gate **strips all three\nbefore anything else**, including before the public-path check. Strip after that early\nreturn and every exempt path becomes an injection point —\n`curl -H 'x-cru-iap-email: admin@cru.org' /health` — for every reader downstream. If you\nhand-roll a gate, use `stripIdentity` / `stampIdentity` in that order; `IDENTITY_HEADERS`\nholds the wire names.\n\nTwo Next.js-specific notes:\n\n- **Middleware runs on the Edge runtime by default**, which has no `node:crypto`. That\n  is why this package is built on `jose` (WebCrypto) rather than `google-auth-library`.\n- **There is no `StripForwardedHost` equivalent, deliberately.** Next.js has no\n  comparable pre-routing slot, and its host handling is configuration (`trustHost`,\n  `allowedDevOrigins`) rather than a middleware you can precede. If your app makes a\n  security decision from the host, make it from an allow-list you control, not from\n  `x-forwarded-host`. See [host resolution](#host-resolution-per-framework).\n\n## Usage (Python)\n\n```python\nfrom cru_iap import verify_request\n\nresult = verify_request(request)\n\nif result.ok:\n    user = provision_user(email=result.email, name=result.name)\nelse:\n    log.warning(\"IAP auth rejected: %s\", result.reason)\n    # fail closed — never fall through to a dev stub\n```\n\n`Result` is a frozen dataclass — so a caller cannot launder a rejection into a pass by\nassignment — and is truthy when `ok`, so `if verify_request(request):` reads fine too.\n`verify_request` accepts a Starlette/FastAPI `Request`, a Django `HttpRequest` (via\neither `.headers` or `.META`), a Flask/Werkzeug `request`, a bare WSGI `environ`, or a\nplain header mapping. `verify(token)` is the lower-level form. Both take `audience`,\n`jwks`, `leeway_seconds` and `log`.\n\nTwo Python-specific notes:\n\n- **The verifier is synchronous**, because PyJWT and its key fetch are. In FastAPI,\n  declare the dependency with `def` rather than `async def` and it runs in a threadpool\n  automatically — which is what you want, since an `async def` dependency would block\n  the event loop on the hourly JWKS refresh.\n- **Logging follows the Python convention** rather than a global setter: the package\n  logs to `logging.getLogger(\"cru_iap\")` with a `NullHandler` attached, so it is silent\n  until your application configures logging.\n\n### Reference wiring (FastAPI dependency)\n\n```python\n# backend/auth.py\nfrom fastapi import Depends, HTTPException, Request\nfrom cru_iap import verify_request\n\ndef current_user(request: Request) -> User:\n    # Gate the dev bypass on deploy config, never on the header being absent.\n    if not settings.iap_audience:\n        return dev_stub_user()\n\n    result = verify_request(request)\n    if not result.ok:\n        log.warning(\"iap_rejected reason=%s\", result.reason)\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n\n    return provision_from_iap(email=result.email, name=result.name)\n```\n\n## Usage (Go)\n\n```go\nimport \"github.com/CruGlobal/cru-iap/cruiap\"\n\nresult := cruiap.VerifyRequest(ctx, request)\n\nif result.OK {\n    user, err := provisionUser(ctx, result.Email, result.Name)\n} else {\n    logger.Warn(\"IAP auth rejected\", \"reason\", result.Reason)\n    // fail closed — never fall through to a dev stub\n}\n```\n\n`Verify` and `VerifyRequest` **never return an error and never panic** — every path\nreturns a `Result`, and a `recover` backstop turns an unanticipated panic into\n`unexpected_error`. That is deliberate: an authentication check must not become a panic\nthat a recover middleware renders as a 500, or that an upstream error path swallows into\na pass. Options are `WithAudience`, `WithKeySource`, `WithLogger`, `WithClockTolerance`\nand `WithClock`.\n\n### Stdlib-only, unlike its siblings\n\nES256 verification is small in Go — base64url and JSON for the envelope, `crypto/ecdsa`\nfor the signature — and skipping a JWT library means the reason vocabulary needs no\ntranslation layer, so every condition is raised where it is detected.\n\nThe tradeoff, stated plainly: the JWS envelope parsing and claim checks are this\npackage's own rather than a widely-audited library's. That is why the suite pins the\nfailure modes a JWT library would otherwise be trusted for — alg confusion across six\n`alg` values including `none`, a truncated and an over-long signature, a public key not\non the curve, and an `exp` that is absent rather than merely past.\n\nTwo Go-specific notes:\n\n- **JWS ES256 signatures are the fixed-width `r||s` form** (RFC 7515 A.3), 32 bytes\n  each — *not* the ASN.1 DER encoding `ecdsa.VerifyASN1` expects and most non-JOSE\n  tooling produces. Getting this wrong fails closed, which is the safe direction, but it\n  is the single most common mistake in a hand-written JWS verifier.\n- **`bad_iss:` is unreachable here, by construction.** The siblings re-assert the issuer\n  in case their JWT library ever stops checking; there is no library to distrust here, so\n  the single check emits `issuer_mismatch`. A test records this, so the gap reads as a\n  decision rather than an oversight.\n\n### Reference wiring (net/http middleware)\n\n```go\nfunc RequireIAP(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        // Gate the dev bypass on deploy config, never on the header being absent.\n        if os.Getenv(\"IAP_AUDIENCE\") == \"\" {\n            next.ServeHTTP(w, r.WithContext(withUser(r.Context(), devStubUser())))\n            return\n        }\n\n        result := cruiap.VerifyRequest(r.Context(), r)\n        if !result.OK {\n            slog.Warn(\"iap_rejected\", \"reason\", result.Reason, \"path\", r.URL.Path)\n            http.Error(w, \"Unauthorized\", http.StatusUnauthorized)\n            return\n        }\n\n        next.ServeHTTP(w, r.WithContext(withEmail(r.Context(), result.Email)))\n    })\n}\n```\n\n## Migrating from application-level OIDC\n\nIf an application currently runs its own Okta OIDC — Auth.js/next-auth, authlib, or\nsimilar — moving behind IAP *deletes* the provider rather than reconfiguring it. What\ngoes away: the provider registration, the login and callback routes, the client secret,\nthe redirect/callback URLs, and any `trustHost`-style setting that existed only to build\nthose callback URLs. If nothing else needs it, the signed session cookie and its secret\ngo too. What remains is the wiring above, plus whatever the callback already did on\nfirst sign-in.\n\nOne inference does *not* survive. Applications relying on *\"any session that exists is\nan allowed user, because the IdP only lets assigned users complete the flow\"* still have\nthat property, but its enforcement moves out of the application and into the\n`roles/iap.httpsResourceAccessor` IAM binding. Read\n[gotcha 10](#gotchas-this-library-encodes) before assuming group membership is still\nvisible application-side — it is not.\n\n## The two IAP control URLs\n\n```ruby\nCruIap.login_url                      # => \"/?login=true\"\nCruIap.login_url(\"/dashboard?tab=1\")  # => \"/dashboard?tab=1&login=true\"\nCruIap.logout_url(\"/bye\")             # => \"/bye?gcp-iap-mode=CLEAR_LOGIN_COOKIE\"\n```\n\n```ts\nimport { loginUrl, logoutUrl } from \"@cruglobal/cru-iap\";\n```\n```python\nfrom cru_iap import login_url, logout_url\n```\n```go\ncruiap.LoginURL(\"/dashboard\")  // \"/dashboard?login=true\"\ncruiap.LogoutURL(\"\")           // \"/?gcp-iap-mode=CLEAR_LOGIN_COOKIE\"\n```\n\nWhy not interpolate a string at the call site — each of these is a real mistake:\n\n- **A bare `/` for sign-in loops forever.** IAP sends `/` to the IdP, the IdP sends it\n  back to `/`.\n- **Sign-out without the cookie-clear mode signs nobody out.** The application session\n  goes away; IAP's federated login cookie does not, and the next request signs the same\n  person straight back in.\n- **A fragment must stay last.** `\"/a#b\"` with `\"?login=true\"` appended naively gives\n  `\"/a#b?login=true\"`, where the parameter sits inside the fragment and never reaches\n  the server.\n- The separator depends on whether a query string is already present. Both helpers are\n  idempotent.\n\nThe two query literals are Google's, and a typo in one language would be a silent\nfailure in that language only — so they are cross-checked across all four by a test.\n\n## The dev bypass\n\n```sh\nCRU_IAP_DEV_BYPASS_EMAIL=you@example.com bin/rails server\n```\n\nCompose it in front of the real verify:\n\n```ruby\nresult = CruIap.dev_bypass || CruIap::TokenVerifier.from_request(request)\n```\n```ts\nconst result = devBypass() ?? (await verifyRequest(request, { audience }));\n```\n```python\nresult = dev_bypass() or verify_request(request, audience=audience)\n```\n```go\nresult, bypassed := cruiap.DevBypass()\nif !bypassed {\n    result = cruiap.VerifyRequest(ctx, r, cruiap.WithAudience(audience))\n}\n```\n\nPutting it first is safe, which is the whole design:\n\n**There is no boolean.** A flag has a wrong default. An identity-carrying variable does\nnot — either you name a developer to be, or you don't, and *unset can only mean no\nbypass*. There is also no `dev_bypass_enabled = true` setter, because anything an\napplication can set in a config file, it can set in production config.\n\n**Two independent guards**, neither depending on the application being written\ncorrectly:\n\n1. `IAP_AUDIENCE` is set → refuse. The bypass cannot coexist with the configuration\n   that means \"this is a real IAP environment\".\n2. A cloud-runtime marker (`K_SERVICE`, `K_REVISION`, `GAE_ENV`, `FUNCTION_TARGET`) is\n   present → refuse. The platform sets these; nobody has to remember to.\n\nThey are independent on purpose: a deploy that somehow lost `IAP_AUDIENCE` is still\nrefused on Cloud Run.\n\nIt returns `dev_bypass`, a reason in the [shared vocabulary](#rejection-reasons), so a\nbypassed request is queryable rather than invisible. The configured address must pass\nthe same shape gate as a real identity — including rejection of namespaced values like\n`sts.google.com:you@example.com`, which are a copy-paste out of a JWT rather than an\naddress. It warns on **every** activation: a bypass that logs once is a bypass someone\nforgets is on.\n\n## Host resolution per framework\n\n`StripForwardedHost` (Ruby) exists because **Rails resolves `request.host` from\n`X-Forwarded-Host` before `Host`**. Google's load balancer preserves `Host` and never\nsets `X-Forwarded-Host`, so a present value is always client-forged. Any application\nkeying behaviour off `request.host` can otherwise be steered by a header.\n\nIt is **not** a universal need:\n\n| | Prefers `X-Forwarded-Host`? | Action |\n|---|---|---|\n| Rails | **yes** (`ActionDispatch::Http::URL#raw_host_with_port`) | `CruIap::StripForwardedHost` at position 0 |\n| Next.js | **yes** — `parseHostHeader` prefers it for the Server Actions CSRF check, and `base-server` sets it with `??=`, so a client value survives (verified in Next 16.2) | strip at the edge, and/or set `serverActions.allowedOrigins` |\n| FastAPI / Starlette | no — host comes from the ASGI scope | none needed |\n| Go `net/http` | no — `r.Host` comes from the request line | none needed |\n\nIdentity is never forgeable this way, since the assertion is signed. This protects the\nrouting layer, not authentication.\n\n## Deployment checklist\n\n- [ ] `IAP_AUDIENCE` set from the Terraform module output\n- [ ] Cloud Run ingress restricted to the load balancer. Verify the deployed service\n      rather than assuming — if ingress is `INGRESS_TRAFFIC_ALL`, the raw `*.run.app`\n      URL reaches the application with **no IAP in front**. If your Terraform module\n      derives this from a load-balancer strategy input, there may be no `--ingress`\n      flag in the deploy chain to set, so check the module rather than the deploy.\n- [ ] Regardless of ingress, every route fails closed on a missing header, and a\n      \"no header → dev stub\" fallback is impossible in production. Use\n      [the dev bypass](#the-dev-bypass) rather than a hand-rolled flag.\n- [ ] `CruIap::StripForwardedHost` inserted at position 0 (Rails), or the equivalent for\n      Next.js. Go and FastAPI need nothing.\n- [ ] Sign-in built with `login_url` — **not** a hand-written `/`, which loops\n- [ ] Sign-out built with `logout_url`, so IAP clears the federated login cookie\n- [ ] Every surface that authenticates *itself* is listed in the IAP\n      [`bypass_paths`](#surfaces-that-authenticate-themselves-need-bypass_paths), not just\n      exempted in application code\n- [ ] Decide what an authenticated-but-unauthorized user sees — see\n      [the access-denied page](#the-access-denied-page)\n\n## Surfaces that authenticate themselves need `bypass_paths`\n\nAn application-side exemption is **not enough on its own**, and this is the constraint\nmost likely to take a cutover down. IAP rejects at the load balancer, before your gate\nruns: a webhook, a cron POST, or an OAuth token endpoint never reaches the middleware\nthat would have waved it through. It gets IAP's 401, or a 302 to a sign-in page it\ncannot complete because there is nobody at a browser.\n\nSo each one needs a path prefix in the IAP configuration's `bypass_paths`, routing it to\na public backend service and skipping both IAP and the sign-in redirect:\n\n```hcl\niap = {\n  members      = [\"principalSet://…/group/YourGroup\"]\n  bypass_paths = [\"/api/\", \"/oauth/\", \"/.well-known/\", \"/up\"]\n}\n```\n\nThe two halves are not redundant — they answer different questions. `bypass_paths`\ndecides *what reaches the application*; the application-side exemption decides *what the\napplication does with what arrives*, since a bypassed path gets no assertion header and\nmust fall back to its own credential (a PAT, a signing secret, an OIDC token it verifies\nitself). Omit the Terraform half and the surface is unreachable; omit the application\nhalf and it is unauthenticated.\n\nEnumerate deliberately — the list is longer than it first looks. Every route your *old*\nauth already skipped is a candidate, and so is every client that holds a credential\nrather than a session. Real applications have needed nine or more: SCIM, an OAuth\nauthorization server and its metadata document, an MCP transport, Slack callbacks, a\nscheduler, a public API, a health probe, and tokenized links for external recipients.\n\n## The access-denied page\n\nIAP can redirect a user who signed in successfully but holds no\n`roles/iap.httpsResourceAccessor` binding to a page you control, instead of its own bare\nerror page. Set `applicationSettings.accessDeniedPageSettings.accessDeniedPageUri`; in\nTerraform, `google_iap_settings` exposes it as\n`application_settings { access_denied_page_settings { access_denied_page_uri = … } }`.\n\nVerified against a live Okta → WIF → IAP sign-in. Five constraints the documentation\ndoes not mention, all measured:\n\n| | |\n|---|---|\n| Scope | **Authorization only.** An unauthenticated request still goes to `auth.cloud.google/authorize`; this page is reached only after a successful sign-in that fails the IAM check. |\n| Parameters | **Yours survive; IAP adds none of its own.** A URI with `?app=foo&reason=no_group` arrives verbatim in the `Location`, so you can encode the application, a support contact, or the group to request. What you cannot get is anything *dynamic* — no identity, no reason, and no troubleshooting link even with `generate_troubleshooting_uri = true`. |\n| `Accept` | **Ignored.** An XHR asking for JSON gets the same cross-origin `302`, so `fetch` follows it and fails on CORS rather than seeing a status it can handle. |\n| Body | The `302` still carries IAP's default \"Access Denied\" HTML, for clients that do not follow redirects. |\n| Entitlement | Google documents this as part of a paid enterprise subscription, yet it applied with nothing purchased for it. **Confirm entitlement before relying on it in production.** |\n\nOne operational note: **IAP IAM changes take well over five minutes to propagate.** A\nbinding you just removed will still let the user through. Wait before concluding the\ndenial path is broken.\n\n## Gotchas this library encodes\n\n1. **`email` is the identity in every mode. `sub` never is.**\n\n   | | `email` claim | `sub` claim |\n   |---|---|---|\n   | Plain IAP (Google / Cloud Identity) | bare address, no prefix | `accounts.google.com:<opaque id>` |\n   | WIF, `google.email` mapped | bare address | `sts.google.com:<opaque STS token>` |\n   | WIF, mapping absent | **absent entirely** | `sts.google.com:<opaque STS token>` |\n\n   There is no mode in which `sub` yields a usable identity, so a `sub` fallback buys\n   nothing and costs diagnostic clarity.\n\n2. **A workforce JWT with no `email` means a missing `google.email` attribute mapping**\n   on the pool provider, and upstream of that a missing `email` attribute statement on\n   the IdP application. Nothing application-side can recover the address. Report it as\n   `missing_email`, which names the actual remedy — a `sub` fallback downgrades that\n   accurate diagnosis into a misleading `malformed_subject`, sending the next reader\n   hunting a principal shape that does not exist.\n\n3. **`principal://…` is in neither `email` nor `sub`.** The workforce principal URI is\n   real, but it lives in the nested `workforce_identity.iam_principal` claim — it is the\n   string IAM bindings match, not an identity. A verifier that unwraps it out of\n   `email`/`sub` is handling a shape IAP never emits, and is *accepting* a value it\n   would otherwise correctly reject. The WIF payload also carries\n   `identity_source: \"WORKFORCE_IDENTITY\"` if you need to branch on federated versus\n   Google sessions.\n\n4. **Split on the first colon; never match a literal prefix.** A real email never\n   contains one, so a leading `<prefix>:` is always the IAP namespace. Observed\n   prefixes: `accounts.google.com:`, `sts.google.com:`, and Identity Platform's\n   `securetoken.google.com/<project>/<tenant>:`.\n\n5. **An email regexp is not a sufficient shape gate on its own.** RFC 5322 permits `/`\n   in a local part, so a URI-shaped value ending in an address would be persisted as a\n   user whose email is that entire string. Reject anything containing a slash as well.\n\n   The interaction with gotcha 4 is the surprising part. The *raw*\n   `principal://iam.googleapis.com/.../subject/alice@example.com` fails an email regexp,\n   but only because of the `principal:` scheme colon — and the verifier strips everything\n   up to the first colon before validating, since that is how it removes the namespace.\n   What survives that strip, `//iam.googleapis.com/.../subject/alice@example.com`,\n   **does** match. Each guard looks redundant alone; together they are not.\n\n6. **Keep `malformed_subject` and `missing_email` distinct.** Nothing arrived, versus\n   something arrived that is not an address: different root causes, different fixes.\n\n7. **Fail closed when `IAP_AUDIENCE` is unset** rather than skipping the audience check.\n\n8. **Require `exp`; do not merely validate it.** The `jwt` gem's `verify_expiration` is\n   a no-op when the claim is *absent*, and googleauth adds no freshness floor — so a\n   validly signed assertion carrying no `exp` would be accepted forever. Not\n   attacker-reachable, since minting one needs Google's IAP signing key, but the\n   verifier should not depend on IAP always setting it.\n\n   **`jose` and PyJWT have the identical hole**, so the TypeScript side passes\n   `requiredClaims: [\"exp\"]` and the Python side `options={\"require\": [\"exp\"]}`. Three\n   independent JWT libraries making the same choice is the default to expect — assume the\n   next one does too, and check rather than trust.\n\n9. **Do not coerce the `email` claim to a string in JavaScript.**\n   `String([\"a@example.com\"])` is `\"a@example.com\"`, so a multi-address array claim\n   would coerce into a single accepted identity. Ruby's `Array#to_s` renders the\n   brackets and rejects, which is why the Ruby verifier can safely `.to_s` and the\n   TypeScript one cannot. Python's `str()` also renders brackets, but the Python\n   verifier still checks `isinstance(raw, str)` explicitly, because relying on `repr()`\n   for a security decision is a coincidence rather than a design.\n\n10. **The assertion carries no group membership.** There is no `groups` claim and\n    nothing from which one can be derived — the entire top-level claim set is `aud`,\n    `azp`, `email`, `exp`, `iat`, `identity_source`, `iss`, `sub`, and the nested\n    `workforce_identity`. This is the most expensive difference from an OIDC `id_token`,\n    because a `groups` claim is how many applications answer *\"may this person be\n    here?\"*.\n\n    The coarse gate moves into infrastructure rather than disappearing:\n    `roles/iap.httpsResourceAccessor` accepts `principalSet://…/group/<group>`, so\n    \"must be in group X\" becomes an IAM binding that IAP enforces before the\n    application is reached. What you cannot do application-side is make a *finer*\n    decision from the group — read a role, branch on department, vary navigation —\n    because the application can no longer see the membership that got the request\n    through the door. Applications needing that must keep their own store.\n\n    The failure mode is silent and **open**, not closed: the claim is simply absent, so a\n    port keeping `required_group.in?(claims[\"groups\"])` rejects everyone, while one\n    keeping `claims[\"groups\"]&.include?` or an `unless groups.blank?` guard admits\n    everyone. Grep for the group claim by name during a cutover rather than trusting\n    tests to catch it.\n\n11. **Load-balancer 302s look like application redirects** in logs. Check which layer\n    issued them.\n\n## Rejection reasons\n\n`REASONS` is a single vocabulary shared across all four languages, so every application\nbehind IAP can file the same queries regardless of stack. Entries ending in `:` carry a\nvariable suffix.\n\n`missing_token` · `missing_audience_config` · `bad_iss:` · `missing_exp` ·\n`missing_email` · `malformed_subject` · `signature_error:` · `audience_mismatch` ·\n`expired_token` · `issuer_mismatch` · `verification_error:` · `unexpected_error` ·\n`iap_jwt` · `dev_bypass`\n\nTwo are successes: `iap_jwt` (a verified assertion) and `dev_bypass`. Everything else is\na rejection. `dev_bypass` is in the vocabulary precisely so a bypassed request appears\nin the same queries as a real one instead of being invisible.\n\nA test in each language asserts its verifier can only produce listed reasons. Across\nlanguages, the Python suite parses the Ruby and TypeScript lists out of their source and\nasserts all three are identical, and the Go suite checks all three against its own — so\na reason added in one place and forgotten in another fails there. The comparison is\nelement-by-element, so add a reason in every language at once, **in the same position**.\n\nThese mappings differ because the underlying libraries do:\n\n| Condition | Ruby (googleauth) | TypeScript (jose) | Python (PyJWT) | Go (stdlib) |\n|---|---|---|---|---|\n| token's `kid` not in the JWKS | `signature_error:Token not verified as issued by Google` | `signature_error:no_matching_key` | `signature_error:no_matching_key` | `signature_error:no_matching_key` |\n| JWKS unreachable / non-200 / unparseable | `verification_error:KeySourceError` | same | same | same |\n| wrong `iss` | `issuer_mismatch`, or `bad_iss:` from the re-assert | same | same | `issuer_mismatch` only |\n\nTwo library quirks are pinned by tests rather than trusted. `PyJWKClient` raises the\nplain base `PyJWKClientError` for two unrelated conditions — no matching `kid`, and a\nJWKS that fetched but would not parse — separable only by message; the connection case\nhas its own subclass. And `jose` reports a non-200 JWKS response as its *base*\n`JOSEError`; keying off that looks alarmingly broad, so it was checked — those are the\nonly two sites in the library that throw the bare base class (jose 6.2), and both are\nthis exact condition.\n\n## What the library logs\n\nThe verifier is silent except for two warnings: `malformed_subject`, which dumps the\nfull JWT payload so an unexpected principal shape is diagnosable without redeploying\ninstrumentation, and the fail-closed catch-all. The payload is identity claims, not\ncredentials — the same sensitivity as the email addresses already in your request logs.\n\nIt defaults to a null logger until you set `CruIap.logger`, pass `logger:` / `log=`, or\nconfigure logging in Python.\n\n## Development\n\n```sh\n# Ruby\nbundle install\nbundle exec rake              # unit + integration, separate processes\n\n# TypeScript\nnpm install\nnpm test                      # unit only — offline, no credentials\nnpm run typecheck\n\n# Python\nuv sync --group dev\nuv run pytest                 # offline, no credentials\n\n# Go\ngo test ./cruiap/             # offline, no credentials, no dependencies\ngo vet ./...\n```\n\nNo default suite touches the network. The end-to-end suites, which verify against real\nGoogle infrastructure, are gated separately in each language — see [`e2e/`](e2e/).\n\n### Releases\n\nAll four libraries share one version and one tag. Releases are cut by\n[release-please](https://github.com/googleapis/release-please), which keeps a standing\n`chore(main): release X.Y.Z` pull request on `main` built from\n[conventional-commit](https://www.conventionalcommits.org/) subjects — this repo scopes\nthem by language (`feat(ts):`, `fix(ruby):`, `docs(python):`).\n\nMerging that PR is the whole release:\n\n1. It bumps the version in all four declared places — `package.json` (+\n   `package-lock.json`), `pyproject.toml`, `cru_iap/__init__.py` and\n   `lib/cru_iap/version.rb`. It has to move all four:\n   `tests/test_package.py::test_the_four_declared_versions_agree` fails the build\n   otherwise.\n2. It writes the new `CHANGELOG.md` section above the previous one.\n3. release-please tags `vX.Y.Z` and publishes a GitHub Release. The tag is what `go get`\n   resolves, so Go needs nothing further.\n4. That Release triggers [`release.yml`](.github/workflows/release.yml), which re-runs the\n   checks and publishes `@cruglobal/cru-iap` to npm and `cru-iap` to PyPI. Both use\n   trusted publishing (OIDC) — there is no registry token in this repository.\n\nThe generated changelog entry is terse by design. **Edit the release PR before merging**\nwhen a change deserves the kind of writeup the 0.1.0 and 0.2.0 entries have — the PR body\nand `CHANGELOG.md` are both editable in place, and the prose is the point of that file.\n\nPre-1.0, features move the minor and breaking changes are capped at minor. The gem is not\npushed to RubyGems; its version moves only to stay in step.\n\n#### The PR title is the commit, and it decides whether a release happens at all\n\n`main` takes squash merges only, and the squash commit's subject is the **PR title** — so\nthat title is the conventional commit release-please reads. Individual commits on the\nbranch are collapsed and never seen.\n\nTwo consequences, and the second one is a trap:\n\n1. Write the PR title as the conventional commit you want in the changelog. A tidy branch\n   history does not help.\n2. **If the title's type is hidden, no release is cut — not even an empty one.**\n   release-please renders the changelog first and skips the release entirely when it comes\n   back empty (`No user facing commits found ... - skipping`). The hidden types are\n   `build`, `ci`, `chore`, `e2e`, `style` and `test`; the visible ones are `feat`, `fix`,\n   `perf`, `revert`, `deps`, `refactor` and `docs`.\n\nSo a PR titled `ci: …` or `chore: …` lands on `main` and produces nothing, silently. That\nis usually right — neither changes anything a consumer installs. When such a change *does*\nneed to ship, either title it for what it delivers to consumers, or follow it with a\nvisible-type commit. Dependabot's default `chore(deps):` prefix is hidden for this same\nreason; a dependency bump that matters to consumers needs a `deps:` title.\n\n## License\n\nMIT. See [LICENSE.txt](LICENSE.txt).\n","readmeFilename":"README.md"}