A mutable role → scope matrix backed by the vk_role_scopes table. The table structure ships with this package’s migrations (vk_auth_003_role_scopes.sql); the rows are your app’s authorization policy — seed them in an app data migration and change them at runtime (e.g. from an admin UI) without redeploying. Every helper takes a caller-supplied Querier (same shape as @venturekit/data’s query), so the package keeps no hard data-layer dependency.
Baseline authorization for any app: single-tenant apps resolve a user’s global role through the resolver and grant the result; multi-tenant apps pass resolver.lookup as scopesByRole to @venturekit-pro/tenancy’s createTenantUserScopesMiddleware.
Function
Signature
Description
listRoleScopes(q)
(Querier) => Promise<Record<string, string[]>>
The full mapping, { role: [scope, …] } (scopes sorted).
lookup:(role:string)=>Promise<readonlystring[]> |readonlystring[]; // pass as scopesByRole
invalidate():void; // drop the cache — the next lookup reloads from the DB
}
Consistency: the mutating instance calls invalidate() and sees the change immediately; other warm instances converge within one TTL. Availability: if a reload fails but a previous snapshot exists, the stale snapshot is served; a cold-start failure with no snapshot throws (failing loudly rather than silently granting nothing to everyone).
Server-side primitives for Lambda handlers fronting a Cognito User Pool. Import from @venturekit/auth/server so the client-safe package root stays free of AWS SDK dependencies. Every Cognito call takes an optional trailing config: AuthServerConfig — { region, userPoolId, appClientId, endpoint?, intent? } — defaulting to loadAuthServerConfig().
Read the pool config from the environment: the canonical COGNITO_* set (first declared auth intent) or, with { intent: 'staff' }, that intent’s AUTH_STAFF_* variables (AUTH_<ID>_ENDPOINT under vk dev)
authIntentEnvPrefix(intentId)
'staff' → 'AUTH_STAFF_'
AuthError
{ code, status } — a stable error surface (invalid_credentials, user_not_confirmed, verification_failed, …) that hides Cognito exception names; status defaults to 401
mapProviderError(err)
Translate a Cognito SDK exception into an AuthError
USER_PASSWORD_AUTH → SignInResult{ idToken, accessToken, refreshToken, expiresIn }. Rejects any challenge (MFA, new password) with an AuthError
signInWithPasswordOrChallenge(credentials)
Same, but returns SignInOrChallengeResult — either tokens or a SignInChallenge{ challenge: 'NEW_PASSWORD_REQUIRED' | 'SOFTWARE_TOKEN_MFA' | 'MFA_SETUP', session, username } for the client to continue
Self sign-up (SignUpResult). autoConfirm defaults to true (admin-confirm + verified email, no Cognito message); with false Cognito sends the verification code. customAttributes ({ role: 'buyer' } → custom:role) are applied server-side through AdminUpdateUserAttributes, never on the SignUp call itself — app clients can no longer write custom:* attributes, so a signed-in user cannot set their own custom:role / custom:scopes. Values are trusted as given: validate anything client-supplied before passing it
Verify against the pool JWKS; verifyAndExplain returns a VerifyOutcome with the failure reason instead of null
decodeJwtClaims(jwt)
Unverified claim extraction — only for tokens received directly from Cognito (e.g. profile claims right after sign-in)
clientMetadata on forgotPassword, signUpUser and adminInviteUser is forwarded verbatim as Cognito ClientMetadata on the one call that makes Cognito send a message, so a customSender trigger receives it as request.clientMetadata (e.g. { locale, notificationContext } to pick a template). It is never logged or returned.
The factor is always an authenticator app (RFC 6238 software token); SMS is deliberately not implemented. Two enrolment paths, decided by where the user enrols — and every Cognito call in a chain returns a fresh session that the next call must use:
Function
Description
beginTotpSetup({ accessToken })
Signed-in user turning MFA on: associates a new secret → TotpSetup{ secret, session }
Answer the SOFTWARE_TOKEN_MFA challenge at sign-in → SignInOrChallengeResult
otpauthUri({ secret, account, issuer })
The otpauth://totp/... URI to render as a QR code
adminGetMfaStatus({ username })
MfaStatus{ totpEnabled, factors }
adminSetTotpPreference({ username, enabled })
Admin-side enable/reset (e.g. lost device)
Each AssociateSoftwareToken call issues a new secret and invalidates the previous one. The pool must declare mfa: 'optional' \| 'required' — see the Authentication guide.
For UXes where the app proves address ownership out-of-band — magic links, emailed codes, OAuth callbacks — and Cognito is only the identity/token backend:
Three Set-Cookie strings (HttpOnly, Secure unless VENTURE_LOCAL=true, refresh cookie scoped to refreshPath, 30-day max age) — pass to setCookie(ctx, …)
buildClearSessionCookies(options?)
The matching expiring cookies for sign-out
resolveCookieDomain(requestHost, options)
Pick the Domain= attribute for the current host (CookieDomainOptions) — pairs with getRequestHost() from the runtime
The CSRF state cookie used by the federated flow (vk_oauth_state_<provider>)
cookieAuthMiddleware(options?)
Authenticate from the session cookie instead of the API Gateway authorizer — cookieName, tokenUse ('id' | 'access'), config, intents (verify against several pools, e.g. ['staff', 'main']), scopeMapper. The default auth middleware createHandler() injects on scoped routes
Emits start (POST /auth/<provider>/start), callback (POST /auth/<provider>/callback) and logout handlers for one provider; onSignIn({ profile, cognitoSub, ctx, accountLinked, created }) is your user/membership upsert hook — accountLinked is true when this sign-in converted a password account (see signInAsFederatedUser)
Emits start (POST /auth/handoff/start) and complete (GET /auth/handoff?code=…&next=…) — moves a session to a host that cannot share cookies (white-label custom domains). authorize is the membership gate; DEFAULT_HANDOFF_COMPLETE_PATH is /auth/handoff
These helpers implement the server-side OAuth Authorization Code
flow. VentureKit does not enable the Cognito Hosted UI; the SPA
runs the redirect dance, the API exchanges the code with the IdP
back-channel (so the client_secret never leaves the server).
Function
Signature
Description
generateOAuthState()
() => string
Mint a random URL-safe state token. The /start route returns it AND pins it to the browser via an HttpOnly cookie.
verifyOAuthState(fromQuery, fromCookie)
(string?, string?) => boolean
Constant-time compare for the /complete CSRF check.
buildAuthorizeUrl(input, env?)
(BuildAuthorizeUrlInput) => Promise<string>
Build the IdP authorize URL the SPA navigates to. Loads client_id from Secrets Manager.
Create the Cognito user on first contact (stamped custom:federated_provider), rotate its server-side password on every sign-in and mint tokens via ADMIN_USER_PASSWORD_AUTH. Returns SignInResult plus created and accountLinked. For an existing password account the behaviour is the onExistingPasswordAccount policy: 'link' (default) replaces the user’s password, stamps the provider attribute and reports accountLinked: true — tell the user; 'reject' throws federated_account_exists (409) and leaves the account untouched.
OTP toolkit for the “we’ll text you a 6-digit code” gate. Pluggable
storage via VerificationCodeStore; channel is opaque ('email' /
'whatsapp' / anything else the application defines).
Function
Signature
Description
generateVerificationCode(length?)
(number?) => string
crypto.randomInt-backed digits. Default 6, range 4–10.
Mint + persist + return plaintext for delivery. Overwrites any previous code for the same (channel, identifier).
verifyVerificationCode(input)
(VerifyVerificationCodeInput) => Promise<void>
Constant-time check; deletes on success, increments attempts on mismatch, wipes after maxAttempts. Throws verification_failed (HTTP 401).
createPostgresVerificationCodeStore(q?)
(Querier?) => VerificationCodeStore
Production store over vk_verification_codes (table ships with this package’s migrations). Pass a transaction’s query to enlist writes in an open transaction; omit for the global pool. Import from @venturekit/auth/server/store/postgres.
createInMemoryVerificationCodeStore()
() => VerificationCodeStore
Tests / vk dev only.
The placeholder secret VentureKit provisions for each declared
AuthIntent.federated provider is named
venturekit/<project>/<stage>/auth/<intent.id>/<provider> and holds
{"clientId":"PLACEHOLDER","clientSecret":"PLACEHOLDER"}. Populate it
after the first deploy:
Enrolment and password-less sign-in against a pool that declares auth[].passkeys (which needs the essentials / plus feature plan — on a lite pool every call throws passkeys_unavailable, 501). Each flow is one pair of calls around a browser round-trip; the credential JSON is the W3C PublicKeyCredential.toJSON() shape, passed through untouched — Cognito verifies it.
Function
Signature
Description
beginPasskeyRegistration(accessToken, config?)
=> Promise<WebAuthnOptionsJson>
Creation options for navigator.credentials.create() (via PublicKeyCredential.parseCreationOptionsFromJSON). Signed-in user.
Finish enrolment with the credential the browser created.
listPasskeys(accessToken, config?)
=> Promise<PasskeyCredential[]>
The user’s enrolled passkeys (credentialId, friendlyName?, transports?, createdAt?) for a “manage devices” screen.
deletePasskey(accessToken, credentialId, config?)
=> Promise<void>
Remove one — the access token scopes it to the caller’s own.
beginPasskeySignIn(username, config?)
=> Promise<PasskeySignInChallenge>
{ session, options } — USER_AUTH with PREFERRED_CHALLENGE=WEB_AUTHN. Throws passkey_not_available (400) when the account has no passkey, so the SPA falls back to the password form.
Answer the challenge and mint tokens — the same SignInResult as signInWithPassword, so cookies and sessions are unchanged. A pool with mfa: 'required' may chain a second factor (auth_challenge_required).
Inbound machine-to-machine auth for your API — a customer’s backend calling you (as opposed to @venturekit/integrations’ apiKey(), which holds keys you use for third-party APIs). A key is shown once; the table keeps its SHA-256 hash and a non-secret display prefix. Keys carry their own scopes, so they plug into the same handler({ scopes }) gate as a JWT. Table vk_api_keys (this package’s migrations); every function takes a Querier — @venturekit/data’s query or a transaction’s.
{ key: ApiKeyRecord, token } — the plaintext token (vk_ + 32 random bytes, base64url) is available only here. ownerId is the Cognito sub the key acts as (ctx.user.id for requests it authenticates)
verifyApiKey(q, token, { touch?, now? })
Live ApiKeyRecord or null for unknown / revoked / expired (hash lookup re-compared in constant time); stamps lastUsedAt unless touch: false
Middleware: acts only when a key is presented (X-Api-Key, or Authorization: Bearer vk_…), otherwise passes through so the cookie / JWT middleware runs as usual — one route can serve both. Populates ctx.user as { id: ownerId ?? key.id, scopes, claims: { auth_method: 'api_key', api_key_id, api_key_name, tenant_id?, …metadata } }. onInvalid: 'reject' (default) answers 401 for an unknown / revoked / expired key; 'ignore' leaves ctx.user null for another middleware to try
“Join as <role> in <tenant>” links that create the Cognito account on acceptance. The framework owns the token lifecycle; the app owns the e-mail that carries the link and the membership row written on acceptance. Table vk_invitations; Querier-based like the API keys.
{ invitation, token } — the plaintext token (32 random bytes, base64url) exists only here; put it in the link. Default lifetime 7 days. An open invitation for the same e-mail + tenant is revoked and reissued (onExisting: 'replace', the “resend” behaviour) or refused with invitation_exists (409, 'reject')
getInvitationByToken(q, token)
The still-open Invitation for the “you’ve been invited to … as …” screen, or null — unknown, used, revoked and expired are indistinguishable on purpose
{ invitation, userSub, created }. Claims the row atomically before the Cognito call (single-use — two concurrent submits cannot both create the account; a failed provisioning releases the claim so the invitee can retry), then creates the account with the invitee’s permanent password. Throws invitation_invalid (410) for a dead token and invitation_email_taken (409) when the address already has an account — ask the invitee to sign in and accept with existingUserSub, which attaches the invitation to their account without creating one
A support agent sees the product as a specific user without ever holding that user’s credentials or tokens: the agent keeps their own session and adds a signed, short-lived grant (“admin A may act as user U until T”). The middleware verifies it, checks the caller is A, swaps ctx.user to U and records who is really there in ctx.user.claims.impersonated_by — every audit row, log line and downstream check can tell.
Opaque token base64url(json).hmac — HMAC-SHA256 with VENTURE_IMPERSONATION_SECRET, falling back to the internal-call secret every deployed function already holds (VENTURE_INTERNAL_HMAC_SECRET); a fixed dev value under VENTURE_LOCAL=true; throws impersonation_unavailable (500) when none is configured. TTL default 900 s, max 3600. Refuses adminSub === targetSub
Runs after authentication and before authorization / tenancy. Reads the grant from X-VK-Impersonate (IMPERSONATION_HEADER); no header → pass-through. An invalid / expired / foreign grant is 403 (impersonation_invalid / impersonation_forbidden) — never a silent fall-through to the admin’s own identity. resolveTarget(targetSub, ctx) builds the impersonated UserContext exactly as a real sign-in would (return null to refuse: deleted user, protected account, other tenant); denyScopes (default ['admin.*'], exact names or prefix.*) strips powers the agent must never wield as the user; onImpersonate({ grant, ctx }) is where the audit row goes
isImpersonated(ctx)
true under a grant — for routes that must refuse writes while “viewing as”
resolveImpersonationSecret(env?)
The signing-secret resolution above, exported for tests and diagnostics
Exported from ./roles/index.js (see the DB-backed role → scopes section above): Querier, RoleScopeRow, RoleScopesLookup, RoleScopesResolver, RoleScopesResolverOptions.