- name
- stacks-auth
- description
- Use when implementing authentication, authorization, passkeys, TOTP/2FA, RBAC, gates, policies, session auth, token management, email verification, password resets, or rate limiting in a Stacks application. Covers the @stacksjs/auth package, config/auth.ts, app/Gates.ts, and app/Middleware/.
- license
- MIT
- compatibility
- Bun >= 1.3.0, TypeScript
- allowed-tools
- Read Edit Write Bash Grep Glob
# Stacks Authentication & Authorization
The `@stacksjs/auth` package provides comprehensive authentication and authorization for Stacks applications, built on `@stacksjs/ts-auth`.
## Key Paths
- Core package source: `storage/framework/core/auth/src/`
- Configuration: `config/auth.ts`
- Security config: `config/security.ts`
- Hashing config: `config/hashing.ts`
- Application gates: `app/Gates.ts`
- Application middleware: `app/Middleware/`
- Middleware aliases: `app/Middleware.ts`
- Auth types: `storage/framework/core/types/src/auth.ts`
## Source Files
```
auth/src/
├── index.ts # All re-exports
├── authentication.ts # Auth class - core auth logic
├── authenticator.ts # 2FA and personal access client
├── client.ts # Client re-exports
├── middleware.ts # Auth middleware handler
├── rate-limiter.ts # RateLimiter class (5 attempts, 15min lockout)
├── passkey.ts # WebAuthn/Passkey support
├── password/reset.ts # Password reset flow
├── register.ts # User registration
├── user.ts # Auth user helpers
├── tokens.ts # Token CRUD, scopes, refresh tokens, OAuth clients
├── gate.ts # Authorization gates & policies
├── policy.ts # BasePolicy class + discovery
├── authorizable.ts # User authorization mixin
├── rbac.ts # Full RBAC system
├── email-verification.ts # Email verification flow
└── session-auth.ts # Session-based SPA auth
```
## Auth Class (authentication.ts) - Static Methods
### Login & Authentication
- `Auth.attempt(credentials: AuthCredentials): Promise<boolean>` - validate credentials without creating token
- `Auth.validate(credentials: AuthCredentials): Promise<boolean>` - checks credentials without setting the request's authenticated user
- `Auth.login(credentials: AuthCredentials, options?: TokenCreateOptions): Promise<{ user, token } | null>` - login and create token
- `Auth.loginUsingId(userId: number, options?: TokenCreateOptions): Promise<{ user, token } | null>` - login by user ID
- `Auth.logout(): Promise<void>` - revoke current token
### Personal access tokens (Sanctum-shaped)
`oauth_access_tokens` is polymorphic: `tokenable_type` holds the owner's TABLE
name (`users`, `authors`) and `tokenable_id` its id there, so any model
declaring `useAuth` can hold tokens - not only `User`.
- `createToken(id, name, scopes, { tokenableType })` - mint one. Returns the
plaintext ONCE (`plainTextToken`); the table stores a hash and nothing can
recover it afterwards. `tokenableType` defaults to `users`.
- `tokens(id, tokenableType?)` - list an owner's live tokens.
- `tokenCan(scope)` / `tokenCanAll` / `tokenCanAny` / `tokenAbilities` - check
the current request's token.
- `revokeToken`, `revokeTokenById`, `revokeAllTokens(id, type?)`,
`revokeOtherTokens(id, type?, { exceptTokenId? })` - revocation also revokes
the paired refresh token, which a raw row delete does not.
- `setTrailActor(id)` - attribute writes in a queue job or CLI run that has no
request to read a user from.
The `PersonalAccessToken` model maps the same table, so `owner.with('tokenable')`
lists exactly what `createToken` minted. It deliberately generates no CRUD
routes: minting and revoking both carry semantics a generic route does not.
- `Auth.once(credentials: AuthCredentials): Promise<boolean>` - one-time auth without token
- `Auth.requestUserTokenWithClient(credentials, clientId, clientSecret): Promise<{ token } | null>` authenticates both a legacy OAuth client and an end user password, then issues a user token. It is not the client credentials grant.
- `Auth.requestToken(...)` is the deprecated compatibility alias for that same legacy exchange. New delegated integrations use the authorization-code provider.
### User State
- `Auth.user(): Promise<UserModel | undefined>` - resolve the current request user through bearer/token-cookie state
- `Auth.check(): Promise<boolean>` - is user authenticated?
- `Auth.guest(): Promise<boolean>` - is user a guest?
- `Auth.id(): Promise<number | undefined>` - get authenticated user ID
- `Auth.setUser(user: UserModel | undefined): void` - set or clear the current request user
### Audited support viewing
Enable `auth.impersonation` with an `eligible(userId)` policy covering suspended
or deleted actors and subjects. `buddy migrate` installs the framework session
and privileged-audit tables when enabled. `migrateImpersonationTables()` is the
explicit installation API for isolated tests. Never install tables in a request.
For tenant-scoped applications, configure `workspaceId(request, subjectId)` to
resolve the viewed account's actual workspace membership. The resolver runs
after support authority checks and its bounded identifier is included in the
durable access audit. A raw client-supplied tenant header is not membership proof.
Grant native RBAC role `super-admin` in guard `platform` through a trusted
operator flow. `Auth.startImpersonation(subjectId, reason)` requires a live
ordinary token, rejects nesting and self-viewing, and returns a separate
15-minute credential. The host should require a fresh password or passkey
confirmation before calling it.
Keep the real login credential. Attach the returned credential in the
`x-stacks-impersonation` header alongside the ordinary bearer. Central
`Auth.getUserFromToken()` resolves the viewed user only after checking the
exact parent session, fresh platform role, actor/subject eligibility, expiry,
revocation and a durable access audit. Support viewing permits GET only. HEAD
is rejected because multiplexed application actions can handle it as a write.
`requestImpersonation()` exposes trusted actor/subject metadata; ORM trails
attribute writes to the original actor. `Auth.endImpersonation(credential)`
requires the original actor request without the support header.
Store viewing state independently from the actor's login and cached profile,
show a persistent disclosure and Exit control, disable offline persistence,
and clear the viewed page on expiry or denial. Platform administration routes
must reject the support header and separately check the current platform role.
`recordPrivilegedAccess(actorId, action, subjectId?)` is a fail-closed audit
primitive for these routes; do not put request bodies or credentials in logs.
For a privileged operation that must immediately observe role revocation by a
different worker, use `hasFreshRole(user, roleName, guardName)` instead of the
process-cached `hasRole`. It reads the configured RBAC store and propagates
store errors. It does not authenticate the caller or create an audit trail;
the operation must do both separately.
### Token Creation
- `Auth.createTokenForUser(user, options?: TokenCreateOptions): Promise<NewAccessToken>`
- `Auth.createToken(user, name?, abilities?): Promise<AuthToken>`
### Token Validation
- `Auth.validateToken(token: string): Promise<boolean>` - validate bearer token
- `Auth.getUserFromToken(token: string): Promise<UserModel | undefined>`
- `Auth.currentAccessToken(): Promise<PersonalAccessToken | undefined>`
### Token Abilities (Scopes)
- `Auth.tokenCan(ability: string): Promise<boolean>`
- `Auth.tokenCant(ability: string): Promise<boolean>`
- `Auth.tokenAbilities(): Promise<string[]>`
- `Auth.tokenCanAll(abilities: string[]): Promise<boolean>`
- `Auth.tokenCanAny(abilities: string[]): Promise<boolean>`
### Token Management
- `Auth.tokens(userId?: number): Promise<PersonalAccessToken[]>`
- `Auth.revokeToken(token: string): Promise<void>`
- `Auth.revokeTokenById(tokenId: number): Promise<void>`
- `Auth.revokeAllTokens(userId?: number): Promise<void>`
- `Auth.revokeOtherTokens(userId?: number, { exceptTokenId? }): Promise<{ keptTokenId: number | null }>` - keeps `exceptTokenId`, else the request's own token; when neither resolves it revokes every token (fail closed)
- `Auth.pruneExpiredTokens(): Promise<number>`
- `Auth.pruneRevokedTokens(): Promise<number>`
- `Auth.rotateToken(oldToken: string): Promise<AuthToken | null>`
- `Auth.findToken(tokenId: number): Promise<PersonalAccessToken | null>`
### Utility
- `Auth.guard(name?: string): typeof Auth` - select guard (returns self)
- `Auth.viaRemember(): boolean` - always false currently
- `Auth.clearState(): void` - clear cached user/token
## Token System (tokens.ts)
### Access Tokens
- `tokens(userId: number): Promise<AccessToken[]>`
- `findToken(plainTextToken: string): Promise<AccessToken | null>`
- `currentAccessToken(): Promise<AccessToken | null>`
- `createToken(userId, name?, scopes?, options?): Promise<PersonalAccessTokenResult>`
- Options: `{ expiresInMinutes?, withRefreshToken?, refreshExpiresInDays? }`
### Refresh Tokens
- `refreshToken(refreshTokenPlain, options?): Promise<RefreshTokenResult>`
- `validateRefreshToken(refreshTokenPlain): Promise<boolean>`
- `revokeRefreshToken(refreshTokenPlain): Promise<void>`
- `revokeAllRefreshTokens(userId): Promise<void>`
- `deleteExpiredRefreshTokens(): Promise<number>`
- `deleteRevokedRefreshTokens(daysOld?): Promise<number>`
### Token Revocation
- `revokeToken(plainTextToken): Promise<void>`
- `revokeTokenById(tokenId): Promise<void>`
- `revokeAllTokens(userId): Promise<void>`
- `revokeOtherTokens(userId, type?, { exceptTokenId? }): Promise<{ keptTokenId: number | null }>` - same rule as `Auth.revokeOtherTokens`
- `deleteExpiredTokens(): Promise<number>`
- `deleteRevokedTokens(daysOld?): Promise<number>`
### Token Scopes
- `tokenCan(scope): Promise<boolean>`
- `tokenCant(scope): Promise<boolean>`
- `tokenCanAll(scopes): Promise<boolean>`
- `tokenCanAny(scopes): Promise<boolean>`
- `tokenAbilities(): Promise<string[]>`
- `parseScopes(scopes: string | string[] | null | undefined): TokenScopes`
### OAuth Clients
- `clients(userId): Promise<OAuthClient[]>`
- `findClient(clientId): Promise<OAuthClient | null>`
- `createClient(options: CreateClientOptions): Promise<CreateClientResult>`
- `revokeClient(clientId): Promise<void>`
### OAuth Provider and PKCE
The authorization server is opt-in through `config/auth.ts` under
`oauthProvider`. It is separate from social sign-in, where Stacks is the OAuth
client. The provider profile is Authorization Code with S256 PKCE and rotating
refresh tokens. Confidential client credentials are opt-in through
`oauthProvider.clientCredentials` and mint access-only tokens without refresh
tokens. Protected resource-server introspection is separately opt-in through
`oauthProvider.introspection`; it authenticates a confidential client and
requires that client to share a configured resource audience with the token.
It does not support implicit or password grants or OpenID Connect.
When enabled, the default auth route bundle registers:
- `GET` and `POST /oauth/authorize`
- `POST /oauth/token`
- `POST /oauth/revoke`
- `GET /.well-known/oauth-authorization-server`
- owner-managed clients under `/auth/oauth/clients`
- user-managed connected applications under `/auth/oauth/connections`
Set a canonical `issuer`, then register every scope and resource explicitly.
Resource audiences are absolute URIs and redirect URIs use exact matching.
The consent view is `auth/oauth/consent` by default and can be overridden with
`oauthProvider.consent.view`. Use `oauthProvider.consent.resolveWorkspace` when
the application must bind consent to current server-owned workspace authority.
Use `oauthProvider.subjectEligibility` when account status is represented by
application data that is not simply the presence of a user row. The callback
is rechecked before consent approval, delegated code exchange and refresh, and
by introspection; returning false revokes the affected grant and reports the
token inactive.
Provider actions return 404 while `oauthProvider.enabled` is false. The token
and revocation endpoints use protocol credentials and intentionally skip
browser CSRF. Authorization approval, client management, and disconnect remain
authenticated and CSRF protected.
- `resolveOAuthProviderConfig(options)` returns `null` unless explicitly enabled
- `generatePkceVerifier()` creates a 256-bit RFC 7636 verifier
- `createS256CodeChallenge(verifier)` derives its S256 challenge
- `verifyS256CodeChallenge(verifier, challenge)` validates without a plain fallback
- `isValidPkceVerifier(value)` checks the required 43 to 128 character syntax
## Two-Factor Authentication (authenticator.ts)
- `generateTwoFactorSecret(): string`
- `generateTwoFactorToken(secret: Secret): Promise<Token>`
- `verifyTwoFactorCode(token: Token, secret: Secret): Promise<boolean>`
- `generateTwoFactorUri(user?, service?, secret?): string`
- `createPersonalAccessClient(): Promise<Result<string, never>>`
### Re-exported from @stacksjs/ts-auth
- `generateTOTP`, `verifyTOTP`, `generateTOTPSecret`, `totpKeyUri`
## Authorization Gates (gate.ts)
Gate registrations, policies and before/after callbacks are shared by native
module copies in one JavaScript process, including separately bundled Actions.
`Gate.flush()` clears those registrations across every copy. This shares no
request identity, token or authorization decision. Native `AuthorizationResponse`
objects can cross bundle copies; arbitrary objects, forged prototypes and
accessor fields are rejected. A conflicting shared-state version/shape refuses
module initialization. This does not synchronize different processes or Workers.
### Gate Functions
- `define<T>(ability: string, callback: GateCallback<T>): void`
- `policy(model: string | { name }, policyClass: new () => Policy): void`
- `before(callback): void` - run before any gate check
- `after(callback): void` - run after any gate check
- `allows(ability, user, ...args): Promise<boolean>`
- `denies(ability, user, ...args): Promise<boolean>`
- `can(ability, user, ...args): Promise<boolean>`
- `cannot(ability, user, ...args): Promise<boolean>`
- `any(abilities[], user, ...args): Promise<boolean>`
- `all(abilities[], user, ...args): Promise<boolean>`
- `none(abilities[], user, ...args): Promise<boolean>`
- `authorize(ability, user, ...args): Promise<AuthorizationResponse>` - throws on deny
- `inspect(ability, user, ...args): Promise<AuthorizationResponse>` - detailed result; invalid callback returns throw
- `has(ability): boolean`
- `hasPolicy(model): boolean`
- `abilities(): string[]`
- `getPolicyFor<T>(model: T): Policy<T> | null`
- `flush(): void` - clear all gates
### Gate Facade - `Gate.define()`, `Gate.can()`, etc
### AuthorizationResponse Class
- `static allow(message?): AuthorizationResponse`
- `static deny(message?, code?): AuthorizationResponse`
- `allowed(): boolean`, `denied(): boolean`
- `authorize(): void` - throws AuthorizationException if denied
### Policy Interface
Methods: `before?`, `viewAny?`, `view?`, `create?`, `update?`, `delete?`, `restore?`, `forceDelete?`
### BasePolicy Abstract Class
Protected helpers: `allow(message?)`, `deny(message?, code?)`, `denyIf(condition)`, `denyUnless(condition)`, `allowIf(condition)`
## RBAC System (rbac.ts)
### Role Management
- `Rbac.createRole(name, guardName?, description?): Promise<RoleRecord>`
- `Rbac.findRole(name, guardName?): Promise<RoleRecord | null>`
Voir sur GitHub