Skip to content

@venturekit/auth API

FunctionDescription
createCognitoConfig(input?)Build a full CognitoConfig from a partial input (passwordPolicy, mfa, tokens deep-merged over DEFAULT_COGNITO_CONFIG)
buildUserPoolConfig(config)Build User Pool infrastructure config
FunctionSignatureDescription
hasScope(roles: string[], scope: string, config: RolesConfig) => booleanCheck if roles grant a scope
hasAnyScope(roles: string[], scopes: string[], config: RolesConfig) => booleanCheck if roles grant any of the scopes
hasAllScopes(roles: string[], scopes: string[], config: RolesConfig) => booleanCheck if roles grant all scopes
getScopesForRoles(roles: string[], config: RolesConfig) => string[]Get all scopes granted by roles
validateRolesConfig(config: RolesConfig) => ValidationResultValidate a roles configuration

DB-backed role → scopes (vk_role_scopes)

Section titled “DB-backed role → scopes (vk_role_scopes)”

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.

FunctionSignatureDescription
listRoleScopes(q)(Querier) => Promise<Record<string, string[]>>The full mapping, { role: [scope, …] } (scopes sorted).
getRoleScopes(q, role)(Querier, string) => Promise<string[]>Scopes granted by one role ([] when unknown).
setRoleScopes(q, role, scopes)(Querier, string, readonly string[]) => Promise<void>Replace a role’s scope set atomically. An empty array removes the role.
grantScopeToRole(q, role, scope)(Querier, string, string) => Promise<void>Add one grant (idempotent).
revokeScopeFromRole(q, role, scope)(Querier, string, string) => Promise<void>Remove one grant (idempotent).
createRoleScopesResolver(options)(RoleScopesResolverOptions) => RoleScopesResolverCached hot-path resolver (below).

createRoleScopesResolver reads the whole table once and caches it per process with a TTL (ttlMs, default 30_000):

interface RoleScopesResolverOptions { querier: Querier; ttlMs?: number }
interface RoleScopesResolver {
lookup: (role: string) => Promise<readonly string[]> | readonly string[]; // 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().

ExportDescription
loadAuthServerConfig(options?)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
FunctionDescription
signInWithPassword({ email | username, password })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
completeNewPasswordChallenge(input) / respondToNewPasswordChallenge(input)Answer NEW_PASSWORD_REQUIRED ({ username, newPassword, session, … })
refreshSession(refreshToken)New id/access tokens (RefreshResult)
revokeRefreshToken(refreshToken)Revoke one refresh token (sign out this device)
globalSignOut(accessToken)Invalidate every session of the user
changePassword({ accessToken, previousPassword, proposedPassword })Self-service password change
forgotPassword({ username, clientMetadata? }) / confirmForgotPassword({ username, code, newPassword })Reset flow. Returns CodeDeliveryDetails; in dev mode the code is delivered through your notify pipeline instead of Cognito
signUpUser({ email, password, name?, attributes?, customAttributes?, autoConfirm?, clientMetadata? })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
verifyAndDecode(jwt, options) / verifyAndExplain(jwt, options)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:

FunctionDescription
beginTotpSetup({ accessToken })Signed-in user turning MFA on: associates a new secret → TotpSetup { secret, session }
confirmTotpSetup({ accessToken, code, deviceName? })Verify the first code from the app
setTotpPreference({ accessToken, enabled })Make TOTP the preferred factor so the next sign-in asks for a code
beginTotpSetupForChallenge({ session })Pool requires MFA and sign-in answered MFA_SETUP (no token exists yet): associate the secret under the challenge session
completeMfaSetupChallenge({ username, code, session })Verify the first code and finish the sign-in → SignInResult. Completing MFA_SETUP makes the factor preferred — no setTotpPreference needed
respondToSoftwareTokenMfa({ username, code, 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:

FunctionDescription
ensureUserForVerifiedEmail({ email, … }) / ensureUserForVerifiedPhone({ phone, … })Idempotently create the Cognito user (invite suppressed, email_verified=true) or fetch the existing one; the returned sub doubles as your users.id
mintSessionForVerifiedEmail(email) / mintSessionForVerifiedPhone(phone)Rotate the user’s password to a throwaway strong secret and sign in — only the tokens leave the function (SignInResult)

Requires ALLOW_USER_PASSWORD_AUTH on the app client and the cognito-idp:Admin* actions the auth intent grants to your Lambdas.

FunctionDescription
adminInviteUser({ username, temporaryPassword, name?, attributes?, customAttributes?, sendCognitoInviteEmail?, clientMetadata? })Create a user with a temporary password (AdminInviteUserResult)
adminUpdateUserAttributes({ username, attributes })Set standard / custom:* attributes
adminDisableUser(username) / adminEnableUser(username) / adminDeleteUser(username)Lifecycle
ExportDescription
ID_TOKEN_COOKIE / ACCESS_TOKEN_COOKIE / REFRESH_TOKEN_COOKIEvk_id_token, vk_access_token, vk_refresh_token
buildSessionCookies(tokens, options?)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
readCookieFromHeader(rawCookieHeader, name) / readCookieFromEvent(event, name)Read one cookie from a header string or an API Gateway v2 event
buildOAuthStateCookie(provider, value, options?) / clearOAuthStateCookie(provider, options?) / oauthStateCookieName(provider)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
extractToken(event) / defaultScopeMapper(claims)Building blocks of the middleware
FunctionDescription
createFederatedAuthRoutes({ provider, onSignIn?, onExistingPasswordAccount?, transactional?, middleware?, config?, cookieOptions? })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)
createSessionHandoffRoutes({ store, authorize, completeUrl?, completePath?, failurePath?, cookieOptions? })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
ExportDescription
issueHandoffCode(store, { sub, refreshToken, targetHost }, ttlSeconds?)Mint a 256-bit single-use code bound to the target host; the store is keyed by its SHA-256 hash (DEFAULT_HANDOFF_TTL_SECONDS = 60)
redeemHandoffCode(store, code, currentHost)Take-and-delete; a wrong-host attempt burns the code
generateHandoffCode() / hashHandoffCode(code)Primitives
HandoffStore{ put(codeHash, payload, ttlSeconds), take(codeHash) } — take must delete atomically (DELETE … RETURNING)

Federated sign-in (@venturekit/auth/server)

Section titled “Federated sign-in (@venturekit/auth/server)”

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).

FunctionSignatureDescription
generateOAuthState()() => stringMint 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?) => booleanConstant-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.
exchangeAuthorizationCode(input, env?)(ExchangeAuthorizationCodeInput) => Promise<FederatedProfile>Back-channel POST to the IdP token endpoint, then resolve the verified profile (Google id_token claims / Facebook /me Graph).
signInAsFederatedUser(input, config?)(SignInAsFederatedUserInput) => Promise<FederatedSignInResult>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.
loadFederatedProviderCredentials(provider, env?)(FederatedProvider) => Promise<FederatedProviderCredentials>Read the OAuth client id/secret from the Secrets Manager placeholder. Cached per Lambda container.

Verification codes (@venturekit/auth/server)

Section titled “Verification codes (@venturekit/auth/server)”

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).

FunctionSignatureDescription
generateVerificationCode(length?)(number?) => stringcrypto.randomInt-backed digits. Default 6, range 4–10.
hashVerificationCode(code)(string) => stringSHA-256 hex digest stored on disk.
requestVerificationCode(input)(RequestVerificationCodeInput) => Promise<{ code, expiresAt }>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?) => VerificationCodeStoreProduction 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()() => VerificationCodeStoreTests / 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:

Terminal window
aws secretsmanager put-secret-value \
--secret-id <arn-from-cfn-output> \
--secret-string '{"clientId":"…","clientSecret":"…"}'

Passkeys / WebAuthn (@venturekit/auth/server)

Section titled “Passkeys / WebAuthn (@venturekit/auth/server)”

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.

FunctionSignatureDescription
beginPasskeyRegistration(accessToken, config?)=> Promise<WebAuthnOptionsJson>Creation options for navigator.credentials.create() (via PublicKeyCredential.parseCreationOptionsFromJSON). Signed-in user.
completePasskeyRegistration(accessToken, credential, config?)=> Promise<void>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.
completePasskeySignIn({ username, session, credential }, config?)=> Promise<SignInResult>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).

Customer API keys (@venturekit/auth/server)

Section titled “Customer API keys (@venturekit/auth/server)”

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.

ExportDescription
createApiKey(q, { name, scopes, ownerId?, tenantId?, metadata?, expiresAt? }){ 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
listApiKeys(q, { ownerId?, tenantId?, includeRevoked? }) / revokeApiKey(q, id) / rotateApiKey(q, id, { expiresAt? })Management: rotate issues a new key with the same name / owner / tenant / scopes / metadata, then revokes the old one
apiKeyAuth({ querier, headerName?, onInvalid?, touch? })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
generateApiKeyToken() / hashApiKeyToken(token) / looksLikeApiKeyToken(value) / API_KEY_TOKEN_PREFIXPrimitives ('vk_' lets a secret scanner recognise a key on sight)

“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.

ExportDescription
createInvitation(q, { email, tenantId?, role?, invitedBy?, metadata?, ttlSeconds?, onExisting? }){ 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
listInvitations(q, { tenantId?, email?, status? })status: 'open' | 'accepted' | 'revoked' | 'expired'
revokeInvitation(q, id)Idempotent; false when unknown or already accepted
acceptInvitation(q, { token, password?, name?, attributes?, customAttributes?, existingUserSub? }, config?){ 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
generateInvitationToken() / hashInvitationToken(token)Primitives

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.

ExportDescription
issueImpersonationGrant({ adminSub, targetSub, ttlSeconds?, reason?, secret?, now? })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
verifyImpersonationGrant(token, { secret?, now? })ImpersonationGrant or null — never throws on malformed input
impersonationMiddleware({ resolveTarget, denyScopes?, headerName?, onImpersonate?, secret? })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
FunctionSignatureDescription
decodeTokenUnsafe(jwt: string) => Record<string, unknown> | nullDecode JWT without verifying the signature. Use only when the token has already been verified upstream (e.g. API Gateway Cognito Authorizer).
decodeToken(jwt: string) => Record<string, unknown> | nullDeprecated alias of decodeTokenUnsafe. Prefer the explicit name.
verifyAndDecode(jwt: string, opts: VerifyOptions) => Promise<Record<string, unknown> | null>Verify the JWT signature against the Cognito JWKS and decode the claims. Use when the token has not been gated upstream.
extractUserFromToken(jwt: string) => User | nullExtract user from ID token
isTokenExpired(jwt: string) => booleanCheck if token is expired
getTokenExpiry(jwt: string) => Date | nullGet token expiry as Date

Exported from ./types/index.js — includes User, Session, Role, Permission, RolesConfig, and related types.

TypeDescription
UserPoolOutputsCognito User Pool deployment outputs
UserPoolInfraConfigCognito infrastructure configuration

Exported from ./roles/index.js (see the DB-backed role → scopes section above): Querier, RoleScopeRow, RoleScopesLookup, RoleScopesResolver, RoleScopesResolverOptions.

ConstantDescription
DEFAULT_COGNITO_CONFIGDefault Cognito configuration