@stacksjs/auth 0.75.15 → 0.75.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +20 -0
  2. package/dist/authentication.d.ts +1 -0
  3. package/dist/authentication.js +1 -1
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.js +1 -1
  6. package/dist/oauth-authorization-codes.d.ts +32 -0
  7. package/dist/oauth-authorization-codes.js +29 -0
  8. package/dist/oauth-authorization-requests.d.ts +9 -0
  9. package/dist/oauth-authorization-requests.js +37 -0
  10. package/dist/oauth-authorization.d.ts +55 -0
  11. package/dist/oauth-authorization.js +1 -0
  12. package/dist/oauth-client-registration.d.ts +78 -0
  13. package/dist/oauth-client-registration.js +78 -0
  14. package/dist/oauth-consent.d.ts +34 -0
  15. package/dist/oauth-consent.js +12 -0
  16. package/dist/oauth-delegated-access.d.ts +23 -0
  17. package/dist/oauth-delegated-access.js +1 -0
  18. package/dist/oauth-grants.d.ts +38 -0
  19. package/dist/oauth-grants.js +45 -0
  20. package/dist/oauth-pkce.d.ts +13 -0
  21. package/dist/oauth-pkce.js +1 -0
  22. package/dist/oauth-provider.d.ts +36 -0
  23. package/dist/oauth-provider.js +1 -0
  24. package/dist/oauth-revocation.d.ts +7 -0
  25. package/dist/oauth-revocation.js +13 -0
  26. package/dist/oauth-token-endpoint.d.ts +8 -0
  27. package/dist/oauth-token-endpoint.js +1 -0
  28. package/dist/oauth-token-exchange.d.ts +60 -0
  29. package/dist/oauth-token-exchange.js +65 -0
  30. package/dist/oauth-token-request.d.ts +36 -0
  31. package/dist/oauth-token-request.js +1 -0
  32. package/dist/oauth-token-response.d.ts +14 -0
  33. package/dist/oauth-token-response.js +1 -0
  34. package/dist/password-rehash.d.ts +10 -0
  35. package/dist/password-rehash.js +1 -0
  36. package/dist/session-auth.js +1 -1
  37. package/dist/tokens.js +23 -9
  38. package/package.json +13 -13
@@ -0,0 +1,38 @@
1
+ /** Persist the exact consent a user approved. Grants are immutable until revoked. */
2
+ export declare function createOAuthGrant(input: CreateOAuthGrantInput): Promise<OAuthGrant>;
3
+ /** Revoke one consent grant. Replays are idempotent and report no new change. */
4
+ export declare function revokeOAuthGrant(id: string): Promise<boolean>;
5
+ /** List the active third-party applications connected to one OAuth subject. */
6
+ export declare function listOAuthConnections(subjectType: string, subjectId: number): Promise<OAuthConnectedApplication[]>;
7
+ /** Disconnect one application without allowing another subject to revoke it. */
8
+ export declare function disconnectOAuthGrant(subjectType: string, subjectId: number, grantId: string): Promise<boolean>;
9
+ export declare interface CreateOAuthGrantInput {
10
+ clientId: number
11
+ subjectType: string
12
+ subjectId: number
13
+ scopes: readonly string[]
14
+ resources: readonly string[]
15
+ audiences: readonly string[]
16
+ workspaceId?: string | null
17
+ }
18
+ export declare interface OAuthGrant {
19
+ id: string
20
+ clientId: number
21
+ subjectType: string
22
+ subjectId: number
23
+ scopes: string[]
24
+ resources: string[]
25
+ audiences: string[]
26
+ workspaceId: string | null
27
+ createdAt: Date
28
+ }
29
+ export declare interface OAuthConnectedApplication {
30
+ grantId: string
31
+ clientId: number
32
+ clientName: string
33
+ scopes: string[]
34
+ resources: string[]
35
+ audiences: string[]
36
+ workspaceId: string | null
37
+ createdAt: Date
38
+ }
@@ -0,0 +1,45 @@
1
+ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function explicitValues(name,values){if(values.some((value)=>!value)||new Set(values).size!==values.length)throw TypeError(`OAuth grant ${name} must contain unique non-empty values.`);return[...values]}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}export async function createOAuthGrant(input){if(!validIdentifier(input.clientId)||!validIdentifier(input.subjectId))throw TypeError("OAuth grant client and subject identifiers must be positive safe integers.");if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType))throw TypeError("OAuth grant subject type is invalid.");if(input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))throw TypeError("OAuth grant workspace identifier must be 1 to 255 characters.");const scopes=explicitValues("scopes",input.scopes),resources=explicitValues("resources",input.resources),audiences=explicitValues("audiences",input.audiences),id=randomBytes(16).toString("hex"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect()),bound=sql.params(id,input.clientId,input.subjectType,input.subjectId,JSON.stringify(scopes),JSON.stringify(resources),JSON.stringify(audiences),input.workspaceId??null,sqlDateTime(createdAt));await db.transaction(async(rawTrx)=>{const trx=rawTrx,clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
2
+ SELECT id FROM oauth_clients
3
+ WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
4
+ LIMIT 1${clientLock}
5
+ `,[input.clientId])).length!==1)throw Error("OAuth client is not active.");await trx.unsafe(`
6
+ INSERT INTO oauth_grants (
7
+ id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id, created_at
8
+ ) VALUES (${bound.sql})
9
+ `,bound.values)});markContextWrote();return{id,clientId:input.clientId,subjectType:input.subjectType,subjectId:input.subjectId,scopes,resources,audiences,workspaceId:input.workspaceId??null,createdAt}}export async function revokeOAuthGrant(id){if(!/^[a-f0-9]{32}$/.test(id))return!1;const sql=sqlHelpers(getDatabaseDialect()),now=sqlDateTime(new Date),result=await db.unsafe(`
10
+ UPDATE oauth_grants
11
+ SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
12
+ WHERE id = ${sql.param(3)} AND revoked_at IS NULL
13
+ `,[now,now,id]).execute(),revoked=mutationCount(result)===1;if(revoked)markContextWrote();return revoked}export async function listOAuthConnections(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
14
+ SELECT g.id AS grant_id, g.client_id, c.name AS client_name, g.scopes,
15
+ g.resources, g.audiences, g.workspace_id, g.created_at
16
+ FROM oauth_grants g
17
+ JOIN oauth_clients c ON c.id = g.client_id AND c.revoked = ${sql.boolFalse}
18
+ WHERE g.subject_type = ${sql.param(1)} AND g.subject_id = ${sql.param(2)}
19
+ AND g.revoked_at IS NULL
20
+ ORDER BY g.created_at DESC, g.id DESC
21
+ `,[subjectType,subjectId])).flatMap((row)=>{const clientId=Number(row.client_id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at);if(!/^[a-f0-9]{32}$/.test(row.grant_id)||!validIdentifier(clientId)||typeof row.client_name!=="string"||!row.client_name||!scopes||!resources||!audiences||!createdAt)return[];return[{grantId:row.grant_id,clientId,clientName:row.client_name,scopes,resources,audiences,workspaceId:row.workspace_id,createdAt}]})}export async function disconnectOAuthGrant(subjectType,subjectId,grantId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId)||!/^[a-f0-9]{32}$/.test(grantId))return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disconnected=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if((await trx.unsafe(`
22
+ SELECT id FROM oauth_grants
23
+ WHERE id = ${sql.param(1)} AND subject_type = ${sql.param(2)}
24
+ AND subject_id = ${sql.param(3)} AND revoked_at IS NULL
25
+ LIMIT 1${lock}
26
+ `,[grantId,subjectType,subjectId])).length!==1)return!1;const now=sqlDateTime(new Date),changed=await trx.unsafe(`
27
+ UPDATE oauth_grants
28
+ SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
29
+ WHERE id = ${sql.param(3)} AND subject_type = ${sql.param(4)}
30
+ AND subject_id = ${sql.param(5)} AND revoked_at IS NULL
31
+ `,[now,now,grantId,subjectType,subjectId]);if(mutationCount(changed)!==1)return!1;await trx.unsafe(`
32
+ UPDATE oauth_auth_codes
33
+ SET consumed_at = ${sql.param(1)}
34
+ WHERE grant_id = ${sql.param(2)} AND consumed_at IS NULL
35
+ `,[now,grantId]);await trx.unsafe(`
36
+ UPDATE oauth_refresh_tokens
37
+ SET revoked = ${sql.boolTrue}
38
+ WHERE access_token_id IN (
39
+ SELECT id FROM oauth_access_tokens WHERE oauth_grant_id = ${sql.param(1)}
40
+ )
41
+ `,[grantId]);await trx.unsafe(`
42
+ UPDATE oauth_access_tokens
43
+ SET revoked = ${sql.boolTrue}, updated_at = ${sql.param(1)}
44
+ WHERE oauth_grant_id = ${sql.param(2)}
45
+ `,[now,grantId]);return!0});if(disconnected)markContextWrote();return disconnected}
@@ -0,0 +1,13 @@
1
+ /** Whether a value is an RFC 7636 code verifier. */
2
+ export declare function isValidPkceVerifier(value: unknown): value is string;
3
+ /** Whether a value is a 256-bit SHA-256 digest encoded as base64url. */
4
+ export declare function isValidS256CodeChallenge(value: unknown): value is string;
5
+ /** Generate a 256-bit, 43-character RFC 7636 verifier. */
6
+ export declare function generatePkceVerifier(): string;
7
+ /** Derive the required S256 challenge from a validated verifier. */
8
+ export declare function createS256CodeChallenge(verifier: string): Promise<string>;
9
+ /**
10
+ * Verify an S256 challenge without supporting the insecure plain method.
11
+ * Malformed protocol input returns false instead of reaching the hash compare.
12
+ */
13
+ export declare function verifyS256CodeChallenge(verifier: string, expectedChallenge: string): Promise<boolean>;
@@ -0,0 +1 @@
1
+ import{createHash,randomBytes,timingSafeEqual}from"node:crypto";const VERIFIER_PATTERN=/^[A-Za-z0-9\-._~]{43,128}$/,S256_CHALLENGE_PATTERN=/^[A-Za-z0-9_-]{43}$/;export function isValidPkceVerifier(value){return typeof value==="string"&&VERIFIER_PATTERN.test(value)}export function isValidS256CodeChallenge(value){return typeof value==="string"&&S256_CHALLENGE_PATTERN.test(value)}export function generatePkceVerifier(){return randomBytes(32).toString("base64url")}export async function createS256CodeChallenge(verifier){if(!isValidPkceVerifier(verifier))throw TypeError("PKCE verifier must be 43 to 128 RFC 7636 unreserved characters.");return createHash("sha256").update(verifier,"ascii").digest("base64url")}export async function verifyS256CodeChallenge(verifier,expectedChallenge){if(!isValidPkceVerifier(verifier)||!isValidS256CodeChallenge(expectedChallenge))return!1;const actual=await createS256CodeChallenge(verifier);return timingSafeEqual(Buffer.from(actual,"ascii"),Buffer.from(expectedChallenge,"ascii"))}
@@ -0,0 +1,36 @@
1
+ import type { OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
2
+ /**
3
+ * Resolve the disabled-by-default OAuth authorization-server contract.
4
+ *
5
+ * The initial provider profile is intentionally fixed to authorization code
6
+ * with S256 PKCE and rotating refresh tokens. Implicit and password grants are
7
+ * not configurable options, so enabling the provider cannot advertise them by
8
+ * accident. Routes consume this resolver before registration and therefore do
9
+ * not exist while the provider is absent or disabled.
10
+ */
11
+ export declare function resolveOAuthProviderConfig(options?: OAuthProviderConfig): ResolvedOAuthProviderConfig | null;
12
+ export declare interface ResolvedOAuthProviderConfig {
13
+ enabled: true
14
+ issuer: string
15
+ endpoints: {
16
+ authorization: string
17
+ token: string
18
+ revocation: string
19
+ introspection: string
20
+ }
21
+ responseTypes: readonly ['code']
22
+ grantTypes: readonly ['authorization_code', 'refresh_token']
23
+ codeChallengeMethods: readonly ['S256']
24
+ clientTypes: readonly ('confidential' | 'public')[]
25
+ scopes: Record<string, OAuthProviderScopeConfig>
26
+ resources: Record<string, OAuthProviderResourceConfig>
27
+ lifetimes: {
28
+ authorizationCode: number
29
+ accessToken: number
30
+ refreshToken: number
31
+ }
32
+ consent: {
33
+ rememberFor: number
34
+ view: string
35
+ }
36
+ }
@@ -0,0 +1 @@
1
+ const DEFAULT_LIFETIMES={authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"};function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function positiveLifetime(name,value){if(!Number.isFinite(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive number of milliseconds.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isFinite(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative number of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");return{rememberFor,view}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)};return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints:{authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)},responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
@@ -0,0 +1,7 @@
1
+ /** Execute one RFC 7009 revocation request without registering a route. */
2
+ export declare function handleOAuthRevocationRequest(input: OAuthRevocationEndpointRequest): Promise<Response>;
3
+ export declare interface OAuthRevocationEndpointRequest {
4
+ body: string
5
+ contentType?: string | null
6
+ authorization?: string | null
7
+ }
@@ -0,0 +1,13 @@
1
+ import{createHash}from"node:crypto";import{db,getDatabaseDialect,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{OAuthTokenRequestError,parseOAuthRevocationRequest}from"./oauth-token-request";import{oauthTokenErrorResponse}from"./oauth-token-response";import{revokeTokenPair}from"./token-revocation";const REVOCATION_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache"};async function delegatedAccessTokenId(request,kind){if(!/^[a-f0-9]{80}$/.test(request.token))return null;const sql=sqlHelpers(getDatabaseDialect()),hash=createHash("sha256").update(request.token).digest("hex"),lock=sql.isSqlite?"":" FOR UPDATE",rows=kind==="access_token"?await db.unsafe(`
2
+ SELECT id FROM oauth_access_tokens
3
+ WHERE token = ${sql.param(1)} AND oauth_client_id = ${sql.param(2)}
4
+ AND oauth_grant_id IS NOT NULL
5
+ LIMIT 1${lock}
6
+ `,[hash,request.clientId]):await db.unsafe(`
7
+ SELECT a.id
8
+ FROM oauth_refresh_tokens r
9
+ JOIN oauth_access_tokens a ON a.id = r.access_token_id
10
+ WHERE r.token = ${sql.param(1)} AND a.oauth_client_id = ${sql.param(2)}
11
+ AND a.oauth_grant_id IS NOT NULL
12
+ LIMIT 1${lock}
13
+ `,[hash,request.clientId]),id=Number(rows[0]?.id);return Number.isSafeInteger(id)&&id>0?id:null}async function revokeOAuthClientToken(request){const order=request.tokenTypeHint==="refresh_token"?["refresh_token","access_token"]:["access_token","refresh_token"];for(const kind of order){const id=await delegatedAccessTokenId(request,kind);if(id==null)continue;await revokeTokenPair({id});return}}export async function handleOAuthRevocationRequest(input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??""),basicRealm:"oauth-revoke"};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthRevocationRequest(input.body,input.authorization);if(!await withAuthenticatedOAuthTokenClient(String(request.clientId),request.clientSecret,async()=>{await revokeOAuthClientToken(request);return!0}))return oauthTokenErrorResponse("invalid_client",responseOptions);return new Response(null,{status:200,headers:REVOCATION_HEADERS})}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
@@ -0,0 +1,8 @@
1
+ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
+ /** Execute one token request against the resolved provider without registering a route. */
3
+ export declare function handleOAuthTokenRequest(provider: ResolvedOAuthProviderConfig, input: OAuthTokenEndpointRequest): Promise<Response>;
4
+ export declare interface OAuthTokenEndpointRequest {
5
+ body: string
6
+ contentType?: string | null
7
+ authorization?: string | null
8
+ }
@@ -0,0 +1 @@
1
+ import{exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
@@ -0,0 +1,60 @@
1
+ import type { AuthorizationCodeGrant, AuthorizationCodeRedemption, AuthorizationCodeResult } from './oauth-authorization-codes';
2
+ /** Atomically consume an authorization code and mint its grant-bound token pair. */
3
+ export declare function exchangeAuthorizationCode(input: ExchangeAuthorizationCodeInput): Promise<AuthorizationCodeResult<DelegatedTokenPair>>;
4
+ /** Authenticate the provider client and exchange its code under one policy lock. */
5
+ export declare function exchangeOAuthAuthorizationCode(input: ExchangeOAuthAuthorizationCodeInput): Promise<OAuthAuthorizationCodeExchangeResult>;
6
+ /** Rotate one delegated refresh token and contain reuse to its token family. */
7
+ export declare function refreshOAuthDelegatedToken(input: RefreshOAuthDelegatedTokenInput): Promise<OAuthRefreshTokenExchangeResult>;
8
+ declare const invalidClient: {
9
+ ok: false;
10
+ reason: 'invalid_client'
11
+ };
12
+ declare const unauthorizedClient: {
13
+ ok: false;
14
+ reason: 'unauthorized_client'
15
+ };
16
+ declare const invalidScope: {
17
+ ok: false;
18
+ reason: 'invalid_scope'
19
+ };
20
+ export declare interface ExchangeAuthorizationCodeInput extends AuthorizationCodeRedemption {
21
+ code: string
22
+ accessTokenLifetimeMs: number
23
+ refreshTokenLifetimeMs: number
24
+ issueRefreshToken?: boolean
25
+ }
26
+ export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAuthorizationCodeInput {
27
+ clientSecret?: string
28
+ }
29
+ export declare interface RefreshOAuthDelegatedTokenInput {
30
+ refreshToken: string
31
+ clientId: number
32
+ clientSecret?: string
33
+ scopes?: readonly string[]
34
+ accessTokenLifetimeMs: number
35
+ refreshTokenLifetimeMs: number
36
+ }
37
+ export declare interface DelegatedTokenPair {
38
+ accessToken: string
39
+ refreshToken?: string
40
+ tokenType: 'Bearer'
41
+ expiresIn: number
42
+ grantId: string
43
+ clientId: number
44
+ subjectType: string
45
+ subjectId: number
46
+ scopes: string[]
47
+ resources: string[]
48
+ audiences: string[]
49
+ workspaceId: string | null
50
+ }
51
+ declare type DelegatedGrant = Omit<AuthorizationCodeGrant, 'redirectUri'>;
52
+ export type OAuthAuthorizationCodeExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
53
+ | typeof invalidClient;
54
+ export type OAuthRefreshTokenExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
55
+ | typeof invalidClient
56
+ | typeof invalidScope
57
+ | typeof unauthorizedClient;
58
+ declare class InactiveOAuthClientError extends Error {
59
+
60
+ }
@@ -0,0 +1,65 @@
1
+ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"};function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validLifetime(lifetimeMs){if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth token lifetime must be a positive safe integer.");return lifetimeMs}function deadline(issuedAt,lifetimeMs,mysql){const value=new Date(issuedAt.getTime()+lifetimeMs);if(!Number.isFinite(value.getTime()))throw TypeError("OAuth token lifetime is outside the supported date range.");if(mysql)value.setUTCMilliseconds(0);return value}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function activeFlag(value){return value===!1||value===0}async function mintDelegatedTokenPair(grant,options){const sql=sqlHelpers(getDatabaseDialect()),accessToken=randomBytes(40).toString("hex"),refreshToken=options.issueRefreshToken?randomBytes(40).toString("hex"):void 0,refreshFamilyId=refreshToken?options.refreshFamilyId??randomBytes(16).toString("hex"):void 0;if(refreshFamilyId&&!/^[a-f0-9]{32}$/.test(refreshFamilyId))throw TypeError("OAuth refresh token family identifier is invalid.");const accessHash=tokenHash(accessToken),refreshHash=refreshToken?tokenHash(refreshToken):void 0,createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const accessExpiresAt=deadline(createdAt,options.accessLifetimeMs,sql.isMysql),refreshExpiresAt=options.issueRefreshToken?deadline(createdAt,options.refreshLifetimeMs,sql.isMysql):void 0,revoked=sql.isPostgres?!1:0,accessValues=sql.params(grant.subjectType,grant.subjectId,grant.subjectId,grant.clientId,accessHash,`oauth:${grant.grantId}`,JSON.stringify(grant.scopes),grant.grantId,JSON.stringify(grant.resources),JSON.stringify(grant.audiences),grant.workspaceId,revoked,sqlDateTime(accessExpiresAt),sqlDateTime(createdAt),sqlDateTime(createdAt));await db.unsafe(`
2
+ INSERT INTO oauth_access_tokens (
3
+ tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes,
4
+ oauth_grant_id, resources, audiences, workspace_id, revoked, expires_at, created_at, updated_at
5
+ ) VALUES (${accessValues.sql})
6
+ `,accessValues.values);const storedAccess=(await db.unsafe(`
7
+ SELECT id, tokenable_type, tokenable_id, oauth_client_id, token, scopes, oauth_grant_id,
8
+ resources, audiences, workspace_id, revoked, expires_at
9
+ FROM oauth_access_tokens WHERE token = ${sql.param(1)} LIMIT 1
10
+ `,[accessHash]))[0];if(!storedAccess||storedAccess.tokenable_type!==grant.subjectType||String(storedAccess.tokenable_id)!==String(grant.subjectId)||String(storedAccess.oauth_client_id)!==String(grant.clientId)||storedAccess.oauth_grant_id!==grant.grantId||JSON.stringify(storedValues(storedAccess.scopes))!==JSON.stringify(grant.scopes)||JSON.stringify(storedValues(storedAccess.resources))!==JSON.stringify(grant.resources)||JSON.stringify(storedValues(storedAccess.audiences))!==JSON.stringify(grant.audiences)||storedAccess.workspace_id!==grant.workspaceId||!activeFlag(storedAccess.revoked)||parseSqlDateTime(storedAccess.expires_at)?.getTime()!==accessExpiresAt.getTime())throw Error("Failed to persist the delegated access token.");if(refreshToken&&refreshHash&&refreshExpiresAt){const refreshValues=sql.params(Number(storedAccess.id),refreshHash,refreshFamilyId,options.refreshParentId??null,revoked,sqlDateTime(refreshExpiresAt),sqlDateTime(createdAt));await db.unsafe(`
11
+ INSERT INTO oauth_refresh_tokens (access_token_id, token, family_id, parent_id, revoked, expires_at, created_at)
12
+ VALUES (${refreshValues.sql})
13
+ `,refreshValues.values);const storedRefresh=(await db.unsafe(`
14
+ SELECT id, access_token_id, token, family_id, parent_id, revoked, expires_at
15
+ FROM oauth_refresh_tokens WHERE token = ${sql.param(1)} LIMIT 1
16
+ `,[refreshHash]))[0];if(!storedRefresh||String(storedRefresh.access_token_id)!==String(storedAccess.id)||storedRefresh.family_id!==refreshFamilyId||String(storedRefresh.parent_id??"")!==String(options.refreshParentId??"")||!activeFlag(storedRefresh.revoked)||parseSqlDateTime(storedRefresh.expires_at)?.getTime()!==refreshExpiresAt.getTime())throw Error("Failed to persist the delegated refresh token.")}return{accessToken,...refreshToken?{refreshToken}:{},tokenType:"Bearer",expiresIn:Math.max(0,Math.floor((accessExpiresAt.getTime()-createdAt.getTime())/1000)),grantId:grant.grantId,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId}}function grantFromRefreshRows(refresh,stored){const clientId=Number(refresh.oauth_client_id),subjectId=Number(refresh.tokenable_id),scopes=storedValues(refresh.scopes),resources=storedValues(refresh.resources),audiences=storedValues(refresh.audiences),grantScopes=storedValues(stored?.grant_scopes??null),grantResources=storedValues(stored?.grant_resources??null),grantAudiences=storedValues(stored?.grant_audiences??null);if(!stored||stored.grant_revoked_at!=null||!refresh.oauth_grant_id||!/^[a-f0-9]{32}$/.test(refresh.oauth_grant_id)||!Number.isSafeInteger(clientId)||clientId<=0||!Number.isSafeInteger(subjectId)||subjectId<=0||!scopes?.length||new Set(scopes).size!==scopes.length||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||String(stored.grant_client_id)!==String(clientId)||stored.grant_subject_type!==refresh.tokenable_type||String(stored.grant_subject_id)!==String(subjectId)||!scopes.every((scope)=>grantScopes.includes(scope))||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||stored.grant_workspace_id!==refresh.workspace_id)return null;return{grantId:refresh.oauth_grant_id,clientId,subjectType:refresh.tokenable_type,subjectId,scopes,resources,audiences,workspaceId:refresh.workspace_id}}async function revokeDelegatedRefreshFamily(refresh){const sql=sqlHelpers(getDatabaseDialect()),revoked=sql.isPostgres?!0:1;if(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id)){await db.unsafe(`
17
+ UPDATE oauth_access_tokens
18
+ SET revoked = ${sql.param(1)}
19
+ WHERE id IN (
20
+ SELECT access_token_id FROM oauth_refresh_tokens WHERE family_id = ${sql.param(2)}
21
+ )
22
+ `,[revoked,refresh.family_id]);await db.unsafe(`
23
+ UPDATE oauth_refresh_tokens
24
+ SET revoked = ${sql.param(1)}
25
+ WHERE family_id = ${sql.param(2)}
26
+ `,[revoked,refresh.family_id]);return}await db.unsafe(`UPDATE oauth_access_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.access_token_id]);await db.unsafe(`UPDATE oauth_refresh_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.id])}export async function exchangeAuthorizationCode(input){const sql=sqlHelpers(getDatabaseDialect()),accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),issueRefreshToken=input.issueRefreshToken!==!1,refreshLifetimeMs=issueRefreshToken?validLifetime(input.refreshTokenLifetimeMs):0;try{return await withAuthorizationCode(input.code,input,async(grant)=>{const clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await db.unsafe(`
27
+ SELECT id FROM oauth_clients
28
+ WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
29
+ LIMIT 1${clientLock}
30
+ `,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})})}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")}))??invalidClient}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
31
+ SELECT r.id, r.family_id, a.oauth_client_id
32
+ FROM oauth_refresh_tokens r
33
+ JOIN oauth_access_tokens a ON a.id = r.access_token_id
34
+ WHERE r.token = ${sql.param(1)}
35
+ LIMIT 1
36
+ `,[hash]))[0];if(!candidate||String(candidate.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};if(candidate.family_id&&/^[a-f0-9]{32}$/.test(candidate.family_id))await db.unsafe(`
37
+ SELECT id FROM oauth_refresh_tokens
38
+ WHERE family_id = ${sql.param(1)}
39
+ ORDER BY id${lock}
40
+ `,[candidate.family_id]);const refresh=(await db.unsafe(`
41
+ SELECT r.id, r.access_token_id, r.token, r.family_id, r.parent_id, r.revoked,
42
+ r.expires_at, r.created_at, a.tokenable_type, a.tokenable_id,
43
+ a.oauth_client_id, a.oauth_grant_id, a.scopes, a.resources, a.audiences,
44
+ a.workspace_id, a.revoked AS access_revoked
45
+ FROM oauth_refresh_tokens r
46
+ JOIN oauth_access_tokens a ON a.id = r.access_token_id
47
+ WHERE r.id = ${sql.param(1)} AND r.token = ${sql.param(2)}
48
+ LIMIT 1${lock}
49
+ `,[candidate.id,hash]))[0];if(!refresh||String(refresh.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};const familyIsValid=Boolean(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id));if(!activeFlag(refresh.revoked)||!activeFlag(refresh.access_revoked)||!familyIsValid){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if((parseSqlDateTime(refresh.expires_at)?.getTime()??0)<=Date.now())return{result:invalidGrant,wrote:!1};const grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grants=await db.unsafe(`
50
+ SELECT id, client_id AS grant_client_id, subject_type AS grant_subject_type,
51
+ subject_id AS grant_subject_id, scopes AS grant_scopes,
52
+ resources AS grant_resources, audiences AS grant_audiences,
53
+ workspace_id AS grant_workspace_id, revoked_at AS grant_revoked_at
54
+ FROM oauth_grants
55
+ WHERE id = ${sql.param(1)}
56
+ LIMIT 1${grantLock}
57
+ `,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
58
+ UPDATE oauth_refresh_tokens
59
+ SET revoked = ${sql.param(1)}
60
+ WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
61
+ `,[revoked,refresh.id,sql.isPostgres?!1:0]),accessClaim=await db.unsafe(`
62
+ UPDATE oauth_access_tokens
63
+ SET revoked = ${sql.param(1)}
64
+ WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
65
+ `,[revoked,refresh.access_token_id,sql.isPostgres?!1:0]);if(mutationCount(refreshClaim)!==1||mutationCount(accessClaim)!==1){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}return{result:{ok:!0,value:await mintDelegatedTokenPair({...grant,scopes},{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken:!0,refreshFamilyId:refresh.family_id,refreshParentId:Number(refresh.id)})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}
@@ -0,0 +1,36 @@
1
+ /** Parse one form-encoded token request without accepting mixed client authentication methods. */
2
+ export declare function parseOAuthTokenRequest(body: string, authorization?: string | null): OAuthTokenRequest;
3
+ /** Parse one RFC 7009 revocation request through the token endpoint's client-authentication rules. */
4
+ export declare function parseOAuthRevocationRequest(body: string, authorization?: string | null): OAuthRevocationRequest;
5
+ export declare interface OAuthClientCredentials {
6
+ clientId: number
7
+ clientSecret?: string
8
+ }
9
+ export declare interface OAuthAuthorizationCodeTokenRequest {
10
+ grantType: 'authorization_code'
11
+ clientId: number
12
+ clientSecret?: string
13
+ code: string
14
+ redirectUri: string
15
+ codeVerifier: string
16
+ }
17
+ export declare interface OAuthRefreshTokenRequest {
18
+ grantType: 'refresh_token'
19
+ clientId: number
20
+ clientSecret?: string
21
+ refreshToken: string
22
+ scopes?: string[]
23
+ }
24
+ export declare interface OAuthRevocationRequest extends OAuthClientCredentials {
25
+ token: string
26
+ tokenTypeHint?: 'access_token' | 'refresh_token'
27
+ }
28
+ export type OAuthTokenRequestErrorCode = | 'invalid_client'
29
+ | 'invalid_request'
30
+ | 'unsupported_grant_type'
31
+ | 'unsupported_token_type';
32
+ export type OAuthTokenRequest = OAuthAuthorizationCodeTokenRequest | OAuthRefreshTokenRequest;
33
+ export declare class OAuthTokenRequestError extends Error {
34
+ public readonly error: OAuthTokenRequestErrorCode;
35
+ constructor(error: OAuthTokenRequestErrorCode, message: string);
36
+ }
@@ -0,0 +1 @@
1
+ import{Buffer}from"node:buffer";export class OAuthTokenRequestError extends Error{error;constructor(error,message){super(message);this.error=error;this.name="OAuthTokenRequestError"}}function reject(error,message){throw new OAuthTokenRequestError(error,message)}function single(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth token parameter must not be repeated: ${name}`);const value=values[0];if(required&&!value)reject("invalid_request",`OAuth token parameter is required: ${name}`);if(value?.includes("\uFFFD"))reject("invalid_request",`OAuth token parameter is not valid UTF-8: ${name}`);return value}function clientIdentifier(value){if(!/^[0-9]{1,20}$/.test(value))reject("invalid_client","OAuth client identifier is invalid.");const id=Number(value);if(!Number.isSafeInteger(id)||id<=0)reject("invalid_client","OAuth client identifier is invalid.");return id}function decodeCredential(value){try{return decodeURIComponent(value.replace(/\+/g," "))}catch{return reject("invalid_client","OAuth client credentials are malformed.")}}function basicCredentials(header){const match=/^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);if(!match)reject("invalid_client","OAuth client authentication must use a valid Basic header.");const encoded=match[1];let bytes;try{bytes=Buffer.from(encoded,"base64")}catch{return reject("invalid_client","OAuth client credentials are malformed.")}if(bytes.toString("base64").replace(/=+$/,"")!==encoded.replace(/=+$/,""))reject("invalid_client","OAuth client credentials are malformed.");let decoded;try{decoded=new TextDecoder("utf-8",{fatal:!0}).decode(bytes)}catch{return reject("invalid_client","OAuth client credentials are not valid UTF-8.")}const separator=decoded.indexOf(":");if(separator<1)reject("invalid_client","OAuth client credentials are malformed.");const clientId=clientIdentifier(decodeCredential(decoded.slice(0,separator))),clientSecret=decodeCredential(decoded.slice(separator+1));if(!clientSecret||clientSecret.length>4096)reject("invalid_client","OAuth client secret is invalid.");return{clientId,clientSecret}}function clientCredentials(params,authorization){const bodyClientId=single(params,"client_id"),bodyClientSecret=single(params,"client_secret");if(authorization){if(bodyClientId!==void 0||bodyClientSecret!==void 0)reject("invalid_request","OAuth client authentication methods must not be combined.");return basicCredentials(authorization)}if(bodyClientSecret!==void 0)reject("invalid_request","OAuth client secrets must use Basic authentication.");return{clientId:clientIdentifier(bodyClientId??"")}}function tokenValue(params,name){const value=single(params,name,!0);if(value.length>4096||/\s/.test(value))reject("invalid_request",`OAuth token parameter is invalid: ${name}`);return value}function requestedScopes(params){const value=single(params,"scope");if(value===void 0)return;const scopes=value.split(" ");if(scopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(scopes).size!==scopes.length)reject("invalid_request","OAuth refresh scope must contain unique scope tokens.");return scopes}export function parseOAuthTokenRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth token request is too large.");const params=new URLSearchParams(body);for(const name of["grant_type","client_id","client_secret","code","redirect_uri","code_verifier","refresh_token","scope"])single(params,name);const credentials=clientCredentials(params,authorization),grantType=single(params,"grant_type",!0);if(grantType==="authorization_code"){const codeVerifier=single(params,"code_verifier",!0);if(!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeVerifier))reject("invalid_request","OAuth PKCE verifier is invalid.");const redirectUri=single(params,"redirect_uri",!0);if(redirectUri.length>2048)reject("invalid_request","OAuth redirect URI is too large.");return{grantType,...credentials,code:tokenValue(params,"code"),redirectUri,codeVerifier}}if(grantType==="refresh_token"){const scopes=requestedScopes(params);return{grantType,...credentials,refreshToken:tokenValue(params,"refresh_token"),...scopes?{scopes}:{}}}return reject("unsupported_grant_type","OAuth grant type is not supported.")}export function parseOAuthRevocationRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth revocation request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth revocation token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}
@@ -0,0 +1,14 @@
1
+ import type { OAuthAuthorizationCodeExchangeResult, OAuthRefreshTokenExchangeResult } from './oauth-token-exchange';
2
+ import type { OAuthTokenRequestErrorCode } from './oauth-token-request';
3
+ /** Return one OAuth error without exposing authentication or grant details. */
4
+ export declare function oauthTokenErrorResponse(error: OAuthTokenProtocolErrorCode, options?: OAuthTokenResponseOptions): Response;
5
+ /** Serialize only the public OAuth token fields and apply mandatory no-store headers. */
6
+ export declare function oauthTokenExchangeResponse(result: OAuthAuthorizationCodeExchangeResult | OAuthRefreshTokenExchangeResult, options?: OAuthTokenResponseOptions): Response;
7
+ export declare interface OAuthTokenResponseOptions {
8
+ clientAuthenticatedWithBasic?: boolean
9
+ basicRealm?: string
10
+ }
11
+ export type OAuthTokenProtocolErrorCode = OAuthTokenRequestErrorCode
12
+ | 'invalid_grant'
13
+ | 'invalid_scope'
14
+ | 'unauthorized_client';
@@ -0,0 +1 @@
1
+ const TOKEN_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache"};export function oauthTokenErrorResponse(error,options={}){const challenge=error==="invalid_client"&&options.clientAuthenticatedWithBasic;return new Response(JSON.stringify({error}),{status:challenge?401:400,headers:{...TOKEN_HEADERS,...challenge?{"WWW-Authenticate":`Basic realm="${options.basicRealm??"oauth-token"}"`}:{}}})}export function oauthTokenExchangeResponse(result,options={}){if(!result.ok)return oauthTokenErrorResponse(result.reason,options);const token=result.value;return new Response(JSON.stringify({access_token:token.accessToken,token_type:token.tokenType,expires_in:token.expiresIn,...token.refreshToken?{refresh_token:token.refreshToken}:{},scope:token.scopes.join(" ")}),{status:200,headers:TOKEN_HEADERS})}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Upgrade an outdated password hash after a successful credential issue.
3
+ *
4
+ * The work stays outside the response path and waits for an enclosing
5
+ * transaction to commit. Any failure is reported but never changes the
6
+ * already-successful login result.
7
+ */
8
+ export declare function schedulePasswordRehash(userId: number, plaintext: string, verifiedHash: string): void;
9
+ /** Wait for background password upgrades in tests. */
10
+ export declare function flushPasswordRehashes(): Promise<void>;
@@ -0,0 +1 @@
1
+ import{config}from"@stacksjs/config";import{db,enqueueAfterCommit}from"@stacksjs/database/runtime";import{log}from"@stacksjs/logging";import{makeHash,needsRehash}from"@stacksjs/security";const pendingRehashes=new Set;function warnAboutRehash(userId,cause){const reason=cause instanceof Error?cause.message:String(cause);try{log.warn(`[auth] Password rehash failed for user#${userId}: ${reason}`)}catch{}}async function rehashPassword(userId,plaintext,verifiedHash){const replacement=await makeHash(plaintext);await db.updateTable("users").set({password:replacement}).where("id","=",userId).where("password","=",verifiedHash).execute()}function queuePasswordRehash(userId,plaintext,verifiedHash){let task;task=new Promise((resolve)=>setTimeout(resolve,0)).then(()=>rehashPassword(userId,plaintext,verifiedHash)).catch((cause)=>warnAboutRehash(userId,cause)).finally(()=>pendingRehashes.delete(task));pendingRehashes.add(task)}export function schedulePasswordRehash(userId,plaintext,verifiedHash){try{if(config.hashing?.rehashOnLogin===!1||!needsRehash(verifiedHash))return;const schedule=()=>queuePasswordRehash(userId,plaintext,verifiedHash);if(!enqueueAfterCommit(schedule))schedule()}catch(cause){warnAboutRehash(userId,cause)}}export async function flushPasswordRehashes(){while(pendingRehashes.size>0)await Promise.allSettled([...pendingRehashes])}
@@ -1 +1 @@
1
- import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}export async function sessionUser(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
1
+ import{HttpError}from"@stacksjs/error-handling";import{log}from"@stacksjs/logging";import{User}from"@stacksjs/orm";import{verifyHash}from"@stacksjs/security";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{getCurrentRequest}from"@stacksjs/router";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{schedulePasswordRehash}from"./password-rehash";function generateSessionId(){const bytes=crypto.getRandomValues(new Uint8Array(32));return Array.from(bytes,(b)=>b.toString(16).padStart(2,"0")).join("")}function readRequestFingerprint(override){if(override)return{ip:override.ip??null,userAgent:override.userAgent??null};const req=getCurrentRequest();if(!req)return{ip:null,userAgent:null};const headers=req.headers,fwd=headers?.get?.("x-forwarded-for")||headers?.get?.("X-Forwarded-For")||"",realIp=headers?.get?.("x-real-ip")||headers?.get?.("X-Real-IP")||"",ip=(fwd?fwd.split(",")[0].trim():"")||realIp||null,userAgent=headers?.get?.("user-agent")||headers?.get?.("User-Agent")||null;return{ip,userAgent}}export function fingerprintMismatch(enforce,stored,current){if(!enforce)return!1;const enforceIp=enforce===!0||!!enforce.ip,enforceUa=enforce===!0||!!enforce.userAgent;if(!enforceIp&&!enforceUa)return!1;if(enforceIp&&current.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&&current.userAgent!=null&&stored.userAgent!=null&&String(stored.userAgent)!==current.userAgent)return!0;return!1}function sessionFingerprintRejected(session){return fingerprintMismatch(config.auth?.session?.enforceFingerprint,{ip:session.ip_address,userAgent:session.user_agent},readRequestFingerprint())}export async function sessionLogin(email,password,fingerprint){const normalizedEmail=(email||"").toLowerCase(),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await User.where("email",email).first(),hashToVerify=user?.password||DUMMY_BCRYPT_HASH,isValid=await verifyHash(password,hashToVerify);if(isRateLimited)throw new HttpError(429,"Too many login attempts. Please try again later.");if(!isValid||!user){await RateLimiter.recordFailedAttempt(normalizedEmail);throw new HttpError(401,"Invalid credentials")}await RateLimiter.resetAttempts(normalizedEmail);const sessionId=generateSessionId(),expiresAt=new Date(Date.now()+86400000);if(getDatabaseDialect()==="mysql")expiresAt.setUTCMilliseconds(0);const{ip,userAgent}=readRequestFingerprint(fingerprint);let persisted;try{persisted=await withVerifiedPassword(user.id,hashToVerify,async()=>{await db.insertInto("sessions").values({id:sessionId,user_id:user.id,ip_address:ip,user_agent:userAgent,payload:"{}",last_activity:Math.floor(Date.now()/1000),expires_at:sqlDateTime(expiresAt)}).execute();const stored=await db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at"]).executeTakeFirst();if(!stored||String(stored.user_id)!==String(user.id)||parseSqlDateTime(stored.expires_at)?.getTime()!==expiresAt.getTime()||(parseSqlDateTime(stored.expires_at)?.getTime()??0)<=Date.now())throw Error("Session insert did not persist a usable credential for the verified user.");return!0})}catch(err){log.error(`[auth] Session persistence failed for user#${user.id}: ${err.message}`);throw new HttpError(500,"Session could not be created. Ensure the `sessions` table exists (run `./buddy migrate`).")}if(!persisted)throw new HttpError(401,"Invalid credentials");schedulePasswordRehash(user.id,password,hashToVerify);log.debug(`[auth] Session created for user#${user.id}`);return{user,sessionId}}export async function sessionLogout(sessionId){await db.transaction(async()=>{await db.deleteFrom("sessions").where("id","=",sessionId).execute();if(await db.primary.selectFrom("sessions").where("id","=",sessionId).select("id").executeTakeFirst())throw new HttpError(500,"Session could not be destroyed.")});log.debug("[auth] Session destroyed")}export async function sessionDestroyAll(userId){try{await db.transaction(async()=>{await db.deleteFrom("sessions").where("user_id","=",userId).execute();if(await db.primary.selectFrom("sessions").where("user_id","=",userId).select("id").executeTakeFirst())throw new HttpError(500,"Sessions could not be destroyed.")})}catch(err){const message=err&&typeof err==="object"&&"message"in err?String(err.message):String(err);if(/^(?:no such table: (?:main\.)?sessions|relation "sessions" does not exist|Table '(?:[^'.]+\.)?sessions' doesn't exist)$/i.test(message))return;throw err}}async function deleteObservedExpiredSession(sessionId,expiresAt){const cleanup=db.deleteFrom("sessions").where("id","=",sessionId);await(expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt)).execute()}export async function sessionUser(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return}if(sessionFingerprintRejected(session)){log.warn("[auth] Session fingerprint mismatch - rejecting request (possible hijack, session left intact)");return}return await User.find(session.user_id)}catch{return}}export async function sessionCheck(sessionId){try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return!0}catch{return!1}}export async function sessionRefresh(sessionId,ttlMs=86400000){if(!Number.isFinite(ttlMs)||ttlMs<=0)return!1;try{const session=await db.primary.selectFrom("sessions").where("id","=",sessionId).selectAll().executeTakeFirst();if(!session)return!1;const expiresAt=parseSqlDateTime(session.expires_at)?.getTime()??0;if(Date.now()>=expiresAt){await deleteObservedExpiredSession(sessionId,session.expires_at);return!1}if(sessionFingerprintRejected(session))return!1;return await db.transaction(async()=>{const mysql=getDatabaseDialect()==="mysql";if(mysql){if(!await db.primary.selectFrom("sessions").where("id","=",sessionId).where("expires_at","=",session.expires_at).select("id").lockForUpdate().executeTakeFirst())return!1}const newExpiry=new Date(Date.now()+ttlMs);if(mysql)newExpiry.setUTCMilliseconds(0);if(!Number.isFinite(newExpiry.getTime())||newExpiry.getTime()<=Date.now())return!1;const lastActivity=Math.floor(Date.now()/1000),result=await db.updateTable("sessions").set({expires_at:sqlDateTime(newExpiry),last_activity:lastActivity}).where("id","=",sessionId).where("expires_at","=",session.expires_at).where("user_id","=",session.user_id).returning("id").executeTakeFirst();if(Date.now()>=expiresAt||Date.now()>=newExpiry.getTime())throw Error("[auth] Session expired during renewal.");if(!result)return!1;let storedQuery=db.primary.selectFrom("sessions").where("id","=",sessionId).select(["user_id","expires_at","last_activity"]);if(mysql)storedQuery=storedQuery.lockForUpdate();const stored=await storedQuery.executeTakeFirst();if(!stored||String(stored.user_id)!==String(session.user_id)||parseSqlDateTime(stored.expires_at)?.getTime()!==newExpiry.getTime()||Number(stored.last_activity)!==lastActivity||Date.now()>=Math.min(expiresAt,newExpiry.getTime()))throw Error("[auth] Session renewal did not persist the requested credential.");return!0})}catch{return!1}}export const SessionAuth={login:sessionLogin,logout:sessionLogout,destroyAll:sessionDestroyAll,user:sessionUser,check:sessionCheck,refresh:sessionRefresh};
package/dist/tokens.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{tokenDate,tokenTimestamps}from"./token-dates";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";function tokenSql(){return sqlHelpers(getDatabaseDialect())}function appNow(){return sqlDateTimeLiteral()}function validTokenExpiry(value){if(value==null)return!0;const expiry=parseSqlDateTime(value);return expiry!=null&&expiry.getTime()>Date.now()}async function verifyRefreshTokenInsert(trx,hash,accessTokenId,expiresAt){const{param}=tokenSql(),rows=await trx.unsafe(`
2
2
  SELECT access_token_id, revoked, expires_at FROM oauth_refresh_tokens WHERE token = ${param(1)}
3
- `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date;deadline.setDate(deadline.getDate()+days);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
3
+ `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date;deadline.setDate(deadline.getDate()+days);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
4
4
  SELECT 1 FROM information_schema.columns
5
5
  WHERE table_schema = DATABASE() AND table_name = ?
6
6
  AND column_name = 'password_changed_at'
@@ -18,13 +18,26 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
18
18
  AND t.tokenable_type = ${param(2)}
19
19
  AND t.revoked = ${boolFalse}
20
20
  ORDER BY t.created_at DESC
21
- `,[userId,tokenableType])).map((row)=>({id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at==null?null:tokenDate(row.expires_at),...tokenTimestamps(row),userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const{boolFalse,param}=tokenSql(),hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
22
- SELECT * FROM oauth_access_tokens
23
- WHERE token = ${param(1)}
24
- AND revoked = ${boolFalse}
25
- AND (expires_at IS NULL OR expires_at > ${appNow()})
21
+ `,[userId,tokenableType])).map((row)=>({id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at==null?null:tokenDate(row.expires_at),...tokenTimestamps(row),userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const{boolFalse,param}=tokenSql(),hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
22
+ SELECT t.*, g.scopes AS grant_scopes FROM oauth_access_tokens t
23
+ LEFT JOIN oauth_grants g ON g.id = t.oauth_grant_id
24
+ LEFT JOIN oauth_clients c ON c.id = g.client_id
25
+ WHERE t.token = ${param(1)}
26
+ AND t.revoked = ${boolFalse}
27
+ AND (t.expires_at IS NULL OR t.expires_at > ${appNow()})
28
+ AND (t.oauth_grant_id IS NULL OR (
29
+ g.id IS NOT NULL
30
+ AND c.revoked = ${boolFalse}
31
+ AND g.revoked_at IS NULL
32
+ AND g.client_id = t.oauth_client_id
33
+ AND g.subject_type = t.tokenable_type
34
+ AND g.subject_id = t.tokenable_id
35
+ AND g.resources = t.resources
36
+ AND g.audiences = t.audiences
37
+ AND (g.workspace_id = t.workspace_id OR (g.workspace_id IS NULL AND t.workspace_id IS NULL))
38
+ ))
26
39
  LIMIT 1
27
- `,[hashedToken]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
40
+ `,[hashedToken]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
28
41
  SELECT id FROM oauth_clients
29
42
  WHERE ${options.clientId===void 0?`personal_access_client = ${boolTrue}`:`id = ${param(1)}`}
30
43
  AND revoked = ${boolFalse} LIMIT 1
@@ -43,7 +56,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
43
56
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
44
57
  VALUES (?, ?, 0, ?, ${appNow()})
45
58
  `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
46
- SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.name, t.scopes
59
+ SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.oauth_grant_id, t.name, t.scopes
47
60
  FROM oauth_refresh_tokens r
48
61
  JOIN oauth_access_tokens t ON r.access_token_id = t.id
49
62
  WHERE ${selector}
@@ -54,7 +67,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
54
67
  )
55
68
  AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
56
69
  LIMIT 1${forUpdate}
57
- `,bindings))[0];if(!refreshRow||refreshRow.access_token_id==null||!validTokenExpiry(refreshRow.expires_at))throw new HttpError(401,"Invalid or expired refresh token");const clientLock=isPostgres?" FOR SHARE":isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
70
+ `,bindings))[0];if(!refreshRow||refreshRow.access_token_id==null||!validTokenExpiry(refreshRow.expires_at))throw new HttpError(401,"Invalid or expired refresh token");if(refreshRow.oauth_grant_id!=null)throw new HttpError(401,"Invalid or expired refresh token");const clientLock=isPostgres?" FOR SHARE":isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
58
71
  SELECT id FROM oauth_clients WHERE id = ${param(1)} AND revoked = ${boolFalse}${clientLock}
59
72
  `,[refreshRow.oauth_client_id])).length!==1)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");await revokeTokenPair({id:refreshRow.access_token_id});const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),issuedAt=Date.now(),expiresAt=new Date(issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);if(isPostgres)await trx.unsafe(`
60
73
  INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
@@ -79,6 +92,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
79
92
  AND EXISTS (
80
93
  SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
81
94
  )
95
+ AND t.oauth_grant_id IS NULL
82
96
  AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
83
97
  LIMIT 1
84
98
  `,[hashedRefreshToken]))[0];return row!=null&&validTokenExpiry(row.expires_at)}export async function revokeRefreshToken(refreshTokenPlain){const{boolTrue,param}=tokenSql(),hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.15",
5
+ "version": "0.75.17",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.18",
61
- "@stacksjs/cache": "0.75.15",
62
- "@stacksjs/config": "0.75.15",
63
- "@stacksjs/database": "0.75.15",
64
- "@stacksjs/email": "0.75.15",
65
- "@stacksjs/env": "0.75.15",
66
- "@stacksjs/error-handling": "0.75.15",
67
- "@stacksjs/logging": "0.75.15",
68
- "@stacksjs/orm": "0.75.15",
69
- "@stacksjs/path": "0.75.15",
70
- "@stacksjs/router": "0.75.15",
71
- "@stacksjs/security": "0.75.15",
72
- "@stacksjs/storage": "0.75.15",
61
+ "@stacksjs/cache": "0.75.17",
62
+ "@stacksjs/config": "0.75.17",
63
+ "@stacksjs/database": "0.75.17",
64
+ "@stacksjs/email": "0.75.17",
65
+ "@stacksjs/env": "0.75.17",
66
+ "@stacksjs/error-handling": "0.75.17",
67
+ "@stacksjs/logging": "0.75.17",
68
+ "@stacksjs/orm": "0.75.17",
69
+ "@stacksjs/path": "0.75.17",
70
+ "@stacksjs/router": "0.75.17",
71
+ "@stacksjs/security": "0.75.17",
72
+ "@stacksjs/storage": "0.75.17",
73
73
  "@stacksjs/ts-auth": "^0.4.4",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },