@stacksjs/auth 0.75.28 → 0.75.30

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.
@@ -7,6 +7,8 @@ export declare function parseOAuthAuthorizationConsentRequest(body: string | URL
7
7
  export declare function loadOAuthAuthorizationConsentView(input: LoadOAuthAuthorizationConsentViewInput): Promise<OAuthAuthorizationConsentView | null>;
8
8
  /** Whether a recent active grant already covers the requested consent. */
9
9
  export declare function hasReusableOAuthConsent(input: ReusableOAuthConsentInput): Promise<boolean>;
10
+ /** Reuse one exact remembered grant without creating a duplicate connection. */
11
+ export declare function reuseOAuthAuthorizationRequestSession(input: ReuseOAuthAuthorizationRequestSessionInput): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationConsentResult> | null>;
10
12
  /** Atomically turn one authenticated browser approval into a grant and code. */
11
13
  export declare function approveOAuthAuthorizationRequestSession(input: ApproveOAuthAuthorizationRequestSessionInput): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationConsentResult>>;
12
14
  /** Consume one browser-bound denial without creating a grant or authorization code. */
@@ -18,6 +20,7 @@ export declare interface ApproveOAuthAuthorizationRequestSessionInput {
18
20
  subjectType: string
19
21
  subjectId: number
20
22
  workspaceId?: string | null
23
+ resolveWorkspaceId?: () => Promise<string | null>
21
24
  authorizationCodeLifetimeMs: number
22
25
  }
23
26
  export declare interface OAuthAuthorizationConsentResult {
@@ -38,6 +41,15 @@ export declare interface ReusableOAuthConsentInput {
38
41
  workspaceId?: string | null
39
42
  rememberForMs: number
40
43
  }
44
+ export declare interface ReuseOAuthAuthorizationRequestSessionInput {
45
+ provider: ResolvedOAuthProviderConfig
46
+ requestId: string
47
+ browserSessionId: string
48
+ subjectType: string
49
+ subjectId: number
50
+ workspaceId?: string | null
51
+ resolveWorkspaceId?: () => Promise<string | null>
52
+ }
41
53
  export declare interface LoadOAuthAuthorizationConsentViewInput {
42
54
  provider: ResolvedOAuthProviderConfig
43
55
  requestId: string
@@ -68,3 +80,6 @@ export declare class OAuthAuthorizationConsentRequestError extends Error {
68
80
  readonly code: 'invalid_request';
69
81
  constructor(message: string);
70
82
  }
83
+ declare class NoReusableOAuthConsentError extends Error {
84
+
85
+ }
@@ -1,5 +1,5 @@
1
- import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}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}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}export async function hasReusableOAuthConsent(input){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return!1;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return!1;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return!1;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`;return(await db.primary.unsafe(`
2
- SELECT g.scopes, g.resources, g.audiences, g.created_at
1
+ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}class NoReusableOAuthConsentError extends Error{}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}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}async function reusableOAuthConsentGrantId(input,options={}){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return null;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return null;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return null;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`,lock=options.lock?sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"":"",rows=await db.primary.unsafe(`
2
+ SELECT g.id, g.scopes, g.resources, g.audiences, g.created_at
3
3
  FROM oauth_grants g
4
4
  JOIN oauth_clients c ON c.id = g.client_id AND c.revoked = ${sql.boolFalse}
5
5
  WHERE g.client_id = ${sql.param(1)}
@@ -8,5 +8,5 @@ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,val
8
8
  AND g.revoked_at IS NULL
9
9
  AND g.created_at >= ${sql.param(4)}
10
10
  AND ${workspacePredicate}
11
- ORDER BY g.created_at DESC, g.id DESC
12
- `,params)).some((row)=>{const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);return(parseSqlDateTime(row.created_at)?.getTime()??0)>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)})}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId:input.workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
11
+ ORDER BY g.created_at DESC, g.id DESC${lock}
12
+ `,params);for(const row of rows){const grantId=String(row.id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at)?.getTime()??0;if(!(/^[a-f0-9]{32}$/.test(grantId)&&createdAt>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)))continue;if(options.exact&&(JSON.stringify(scopes)!==JSON.stringify(input.request.scopes)||JSON.stringify(resources)!==JSON.stringify(input.request.resources)||JSON.stringify(audiences)!==JSON.stringify(input.request.audiences)))continue;return grantId}return null}export async function hasReusableOAuthConsent(input){return await reusableOAuthConsentGrantId(input)!==null}export async function reuseOAuthAuthorizationRequestSession(input){if(input.provider.consent.rememberFor<=0)return null;try{return await withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new NoReusableOAuthConsentError;const grantId=await reusableOAuthConsentGrantId({request,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId,rememberForMs:input.provider.consent.rememberFor},{exact:!0,lock:!0});if(!grantId)throw new NoReusableOAuthConsentError;return{code:await issueAuthorizationCode({grantId,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.provider.lifetimes.authorizationCode}),grantId,redirectUri:request.redirectUri,state:request.state}})}catch(error){if(error instanceof NoReusableOAuthConsentError)return null;throw error}}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new OAuthAuthorizationRequestError("invalid_request","OAuth consent workspace changed after it was displayed.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
@@ -2,6 +2,8 @@
2
2
  export declare function createOAuthGrant(input: CreateOAuthGrantInput): Promise<OAuthGrant>;
3
3
  /** Revoke one consent grant. Replays are idempotent and report no new change. */
4
4
  export declare function revokeOAuthGrant(id: string): Promise<boolean>;
5
+ /** Revoke one subject's delegated grants and pending codes during recovery. */
6
+ export declare function revokeOAuthSubjectAuthorizationState(subjectType: string, subjectId: number): Promise<void>;
5
7
  /** List the active third-party applications connected to one OAuth subject. */
6
8
  export declare function listOAuthConnections(subjectType: string, subjectId: number): Promise<OAuthConnectedApplication[]>;
7
9
  /** Disconnect one application without allowing another subject to revoke it. */
@@ -1,8 +1,11 @@
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}
1
+ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{oauthAuthorizationClientFromStored}from"./oauth-client-policy";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);if(scopes.length===0)throw TypeError("OAuth grant must contain at least one scope.");if(resources.length!==audiences.length)throw TypeError("OAuth grant resource and audience bindings must have equal lengths.");const 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":"",clients=await trx.unsafe(`
2
+ SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
3
+ token_endpoint_auth_method, allowed_scopes, allowed_resources,
4
+ personal_access_client, password_client, revoked
5
+ FROM oauth_clients
6
+ WHERE id = ${sql.param(1)}
4
7
  LIMIT 1${clientLock}
5
- `,[input.clientId])).length!==1)throw Error("OAuth client is not active.");await trx.unsafe(`
8
+ `,[input.clientId]),client=oauthAuthorizationClientFromStored(clients[0],input.clientId);if(!client||client.revoked)throw Error("OAuth client is not an active provider authorization client.");if(scopes.some((scope)=>!client.scopes.includes(scope)))throw Error("OAuth grant scope is not registered to the client.");if(resources.some((resource)=>!client.resources.includes(resource)))throw Error("OAuth grant resource is not registered to the client.");await trx.unsafe(`
6
9
  INSERT INTO oauth_grants (
7
10
  id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id, created_at
8
11
  ) VALUES (${bound.sql})
@@ -10,7 +13,26 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
10
13
  UPDATE oauth_grants
11
14
  SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
12
15
  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(`
16
+ `,[now,now,id]).execute(),revoked=mutationCount(result)===1;if(revoked)markContextWrote();return revoked}export async function revokeOAuthSubjectAuthorizationState(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))throw TypeError("OAuth authorization subject is invalid.");const sql=sqlHelpers(getDatabaseDialect()),now=sqlDateTime(new Date);try{await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.unsafe(`
17
+ UPDATE oauth_grants
18
+ SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
19
+ WHERE subject_type = ${sql.param(3)} AND subject_id = ${sql.param(4)}
20
+ AND revoked_at IS NULL
21
+ `,[now,now,subjectType,subjectId]);await trx.unsafe(`
22
+ UPDATE oauth_auth_codes
23
+ SET consumed_at = ${sql.param(1)}
24
+ WHERE subject_type = ${sql.param(2)} AND subject_id = ${sql.param(3)}
25
+ AND consumed_at IS NULL
26
+ `,[now,subjectType,subjectId]);if((await trx.unsafe(`
27
+ SELECT id FROM oauth_grants
28
+ WHERE subject_type = ${sql.param(1)} AND subject_id = ${sql.param(2)}
29
+ AND revoked_at IS NULL
30
+ UNION ALL
31
+ SELECT code_hash FROM oauth_auth_codes
32
+ WHERE subject_type = ${sql.param(3)} AND subject_id = ${sql.param(4)}
33
+ AND consumed_at IS NULL
34
+ LIMIT 1
35
+ `,[subjectType,subjectId,subjectType,subjectId])).length>0)throw Error("[auth] OAuth authorization state could not be revoked.")})}catch(error){const message=error&&typeof error==="object"&&"message"in error&&typeof error.message==="string"?error.message:String(error);if(/^(?:no such table: (?:main\.)?oauth_(?:grants|auth_codes)|relation "oauth_(?:grants|auth_codes)" does not exist|Table '(?:[^'.]+\.)?oauth_(?:grants|auth_codes)' doesn't exist)$/i.test(message))return;throw error}markContextWrote()}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
36
  SELECT g.id AS grant_id, g.client_id, c.name AS client_name, g.scopes,
15
37
  g.resources, g.audiences, g.workspace_id, g.created_at
16
38
  FROM oauth_grants g
@@ -1,4 +1,6 @@
1
1
  import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
+ /** Derive the RFC 8414 well-known path, including an issuer path when present. */
3
+ export declare function oauthAuthorizationServerMetadataPath(issuer: string): string;
2
4
  /** Build RFC 8414 metadata from the provider profile Stacks actually serves. */
3
5
  export declare function oauthAuthorizationServerMetadata(provider: ResolvedOAuthProviderConfig): OAuthAuthorizationServerMetadata;
4
6
  export declare interface OAuthAuthorizationServerMetadata {
@@ -1 +1 @@
1
- function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function oauthAuthorizationServerMetadata(provider){const clientMethods=clientAuthenticationMethods(provider);return{issuer:provider.issuer,authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()}}
1
+ function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function oauthAuthorizationServerMetadataPath(issuer){const issuerPath=new URL(issuer).pathname.replace(/^\/+|\/+$/g,"");return`/.well-known/oauth-authorization-server${issuerPath?`/${issuerPath}`:""}`}export function oauthAuthorizationServerMetadata(provider){const clientMethods=clientAuthenticationMethods(provider);return{issuer:provider.issuer,authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()}}
@@ -1,4 +1,6 @@
1
- import type { OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
1
+ import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
2
+ /** Resolve and validate application-owned workspace authority for consent. */
3
+ export declare function resolveOAuthConsentWorkspace(provider: ResolvedOAuthProviderConfig, context: OAuthProviderConsentWorkspaceContext): Promise<OAuthProviderConsentWorkspace | null>;
2
4
  /**
3
5
  * Resolve the disabled-by-default OAuth authorization-server contract.
4
6
  *
@@ -16,7 +18,6 @@ export declare interface ResolvedOAuthProviderConfig {
16
18
  authorization: string
17
19
  token: string
18
20
  revocation: string
19
- introspection: string
20
21
  }
21
22
  responseTypes: readonly ['code']
22
23
  grantTypes: readonly ['authorization_code', 'refresh_token']
@@ -33,5 +34,6 @@ export declare interface ResolvedOAuthProviderConfig {
33
34
  consent: {
34
35
  rememberFor: number
35
36
  view: string
37
+ resolveWorkspace?: OAuthProviderConsentWorkspaceResolver
36
38
  }
37
39
  }
@@ -1 +1 @@
1
- const DEFAULT_LIFETIMES={authorizationRequest:600000,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={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),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)}}
1
+ import{oauthAuthorizationServerMetadataPath}from"./oauth-metadata";const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke"},RESERVED_AUTH_GET_PATHS=new Set(["/generate-registration-options","/generate-authentication-options"]),RESERVED_AUTH_POST_PATHS=new Set(["/login","/register","/verify-registration","/verify-authentication","/generate-two-factor-secret","/enable-two-factor","/disable-two-factor","/verify-two-factor-login"]);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 assertEndpointRouteAvailable(name,value,methods){const pathname=new URL(value).pathname,reservedNamespace=pathname==="/auth"||pathname.startsWith("/auth/"),reservedMethod=methods.some((method)=>method==="GET"?RESERVED_AUTH_GET_PATHS.has(pathname):RESERVED_AUTH_POST_PATHS.has(pathname));if(reservedNamespace||reservedMethod)throw Error(`auth.oauthProvider ${name} endpoint must not shadow a reserved auth route: ${pathname}`)}function positiveLifetime(name,value){if(!Number.isSafeInteger(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive safe integer of milliseconds.`);if(!Number.isFinite(new Date(Date.now()+value).getTime()))throw Error(`auth.oauthProvider.lifetimes.${name} is outside the supported date range.`);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.isSafeInteger(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative safe integer 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.");const resolveWorkspace=options?.resolveWorkspace;if(resolveWorkspace!==void 0&&typeof resolveWorkspace!=="function")throw Error("auth.oauthProvider.consent.resolveWorkspace must be a function.");return{rememberFor,view,...resolveWorkspace?{resolveWorkspace}:{}}}export async function resolveOAuthConsentWorkspace(provider,context){const resolved=await provider.consent.resolveWorkspace?.(context)??null;if(resolved===null)return null;if(typeof resolved.id!=="string"||!resolved.id||resolved.id.length>255||/[\u0000-\u001F\u007F]/.test(resolved.id))throw Error("OAuth consent workspace id must contain 1 to 255 characters without controls.");if(typeof resolved.label!=="string"||!resolved.label.trim()||resolved.label.length>255||/[\u0000-\u001F\u007F]/.test(resolved.label))throw Error("OAuth consent workspace label must contain 1 to 255 characters without controls.");return{id:resolved.id,label:resolved.label.trim()}}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={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),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)},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)};if(new Set(Object.values(endpoints)).size!==Object.keys(endpoints).length)throw Error("auth.oauthProvider endpoints must each use a unique URL.");assertEndpointRouteAvailable("authorization",endpoints.authorization,["GET","POST"]);assertEndpointRouteAvailable("token",endpoints.token,["POST"]);assertEndpointRouteAvailable("revocation",endpoints.revocation,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
@@ -0,0 +1,10 @@
1
+ /** Delete expired OAuth browser requests and codes without touching active rows. */
2
+ export declare function pruneOAuthAuthorizationArtifacts(options?: OAuthAuthorizationPruneOptions): Promise<OAuthAuthorizationPruneResult>;
3
+ export declare interface OAuthAuthorizationPruneOptions {
4
+ now?: Date
5
+ consumedRetentionMs?: number
6
+ }
7
+ export declare interface OAuthAuthorizationPruneResult {
8
+ authorizationRequests: number
9
+ authorizationCodes: number
10
+ }
@@ -0,0 +1,9 @@
1
+ import{db,getDatabaseDialect,markContextWrote,mutationCount,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";export async function pruneOAuthAuthorizationArtifacts(options={}){const now=options.now??new Date,consumedRetentionMs=options.consumedRetentionMs??604800000;if(!Number.isFinite(now.getTime()))throw TypeError("OAuth authorization prune time must be a valid date.");if(!Number.isFinite(consumedRetentionMs)||consumedRetentionMs<0)throw TypeError("OAuth authorization consumed retention must be a non-negative number of milliseconds.");const sql=sqlHelpers(getDatabaseDialect()),nowSql=sqlDateTime(now),consumedBefore=sqlDateTime(new Date(now.getTime()-consumedRetentionMs)),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,requests=await trx.unsafe(`
2
+ DELETE FROM oauth_authorization_requests
3
+ WHERE expires_at <= ${sql.param(1)}
4
+ OR (consumed_at IS NOT NULL AND consumed_at <= ${sql.param(2)})
5
+ `,[nowSql,consumedBefore]),codes=await trx.unsafe(`
6
+ DELETE FROM oauth_auth_codes
7
+ WHERE expires_at <= ${sql.param(1)}
8
+ OR (consumed_at IS NOT NULL AND consumed_at <= ${sql.param(2)})
9
+ `,[nowSql,consumedBefore]);return{authorizationRequests:mutationCount(requests),authorizationCodes:mutationCount(codes)}});if(result.authorizationRequests>0||result.authorizationCodes>0)markContextWrote();return result}
@@ -1,8 +1,12 @@
1
+ import type { OAuthSubjectEligibility } from './oauth-token-exchange';
1
2
  import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
3
  /** 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 function handleOAuthTokenRequest(provider: ResolvedOAuthProviderConfig, input: OAuthTokenEndpointRequest, dependencies: OAuthTokenEndpointDependencies): Promise<Response>;
4
5
  export declare interface OAuthTokenEndpointRequest {
5
6
  body: string
6
7
  contentType?: string | null
7
8
  authorization?: string | null
8
9
  }
10
+ export declare interface OAuthTokenEndpointDependencies {
11
+ isSubjectEligible: OAuthSubjectEligibility
12
+ }
@@ -1 +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}}
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,dependencies){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,isSubjectEligible:dependencies.isSubjectEligible});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,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
@@ -1,4 +1,5 @@
1
1
  import type { AuthorizationCodeGrant, AuthorizationCodeRedemption, AuthorizationCodeResult } from './oauth-authorization-codes';
2
+ import type { OAuthDelegatedSubject } from './oauth-delegated-access';
2
3
  /** Atomically consume an authorization code and mint its grant-bound token pair. */
3
4
  export declare function exchangeAuthorizationCode(input: ExchangeAuthorizationCodeInput): Promise<AuthorizationCodeResult<DelegatedTokenPair>>;
4
5
  /** Authenticate the provider client and exchange its code under one policy lock. */
@@ -22,6 +23,7 @@ export declare interface ExchangeAuthorizationCodeInput extends AuthorizationCod
22
23
  accessTokenLifetimeMs: number
23
24
  refreshTokenLifetimeMs: number
24
25
  issueRefreshToken?: boolean
26
+ isSubjectEligible?: OAuthSubjectEligibility
25
27
  }
26
28
  export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAuthorizationCodeInput {
27
29
  clientSecret?: string
@@ -33,6 +35,7 @@ export declare interface RefreshOAuthDelegatedTokenInput {
33
35
  scopes?: readonly string[]
34
36
  accessTokenLifetimeMs: number
35
37
  refreshTokenLifetimeMs: number
38
+ isSubjectEligible?: OAuthSubjectEligibility
36
39
  }
37
40
  export declare interface DelegatedTokenPair {
38
41
  accessToken: string
@@ -48,6 +51,7 @@ export declare interface DelegatedTokenPair {
48
51
  audiences: string[]
49
52
  workspaceId: string | null
50
53
  }
54
+ export type OAuthSubjectEligibility = (subject: OAuthDelegatedSubject) => boolean | Promise<boolean>;
51
55
  declare type DelegatedGrant = Omit<AuthorizationCodeGrant, 'redirectUri'>;
52
56
  export type OAuthAuthorizationCodeExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
53
57
  | typeof invalidClient;
@@ -1,4 +1,10 @@
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(`
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";import{disconnectOAuthGrant}from"./oauth-grants";import{verifyS256CodeChallenge}from"./oauth-pkce";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"},inactiveSubject=Symbol("inactive-oauth-subject");function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}async function containAuthorizationCodeReplay(input){if(!/^[A-Za-z0-9_-]{43}$/.test(input.code))return;const sql=sqlHelpers(getDatabaseDialect()),code=(await db.unsafe(`
2
+ SELECT grant_id, client_id, subject_type, subject_id, redirect_uri,
3
+ code_challenge, code_challenge_method, consumed_at
4
+ FROM oauth_auth_codes
5
+ WHERE code_hash = ${sql.param(1)}
6
+ LIMIT 1
7
+ `,[tokenHash(input.code)]))[0],subjectId=Number(code?.subject_id);if(!code?.consumed_at||!code.grant_id||!/^[a-f0-9]{32}$/.test(code.grant_id)||String(code.client_id)!==String(input.clientId)||!Number.isSafeInteger(subjectId)||subjectId<=0||code.redirect_uri!==input.redirectUri||code.code_challenge_method!=="S256"||!await verifyS256CodeChallenge(input.codeVerifier,code.code_challenge))return;await disconnectOAuthGrant(code.subject_type,subjectId,code.grant_id)}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
8
  INSERT INTO oauth_access_tokens (
3
9
  tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes,
4
10
  oauth_grant_id, resources, audiences, workspace_id, revoked, expires_at, created_at, updated_at
@@ -23,11 +29,11 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
23
29
  UPDATE oauth_refresh_tokens
24
30
  SET revoked = ${sql.param(1)}
25
31
  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(`
32
+ `,[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{const result=await withAuthorizationCode(input.code,input,async(grant)=>{const clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await db.unsafe(`
27
33
  SELECT id FROM oauth_clients
28
34
  WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
29
35
  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(`
36
+ `,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return inactiveSubject}return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})});if(!result.ok)return result;return result.value===inactiveSubject?invalidGrant:{ok:!0,value:result.value}}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)=>{const exchanged=await exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")});if(!exchanged.ok)await containAuthorizationCodeReplay(exchange);return exchanged})??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
37
  SELECT r.id, r.family_id, a.oauth_client_id
32
38
  FROM oauth_refresh_tokens r
33
39
  JOIN oauth_access_tokens a ON a.id = r.access_token_id
@@ -46,7 +52,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
46
52
  JOIN oauth_access_tokens a ON a.id = r.access_token_id
47
53
  WHERE r.id = ${sql.param(1)} AND r.token = ${sql.param(2)}
48
54
  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(`
55
+ `,[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.isSqlite?"":" FOR UPDATE",grants=await db.unsafe(`
50
56
  SELECT id, client_id AS grant_client_id, subject_type AS grant_subject_type,
51
57
  subject_id AS grant_subject_id, scopes AS grant_scopes,
52
58
  resources AS grant_resources, audiences AS grant_audiences,
@@ -54,7 +60,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
54
60
  FROM oauth_grants
55
61
  WHERE id = ${sql.param(1)}
56
62
  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(`
63
+ `,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");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
64
  UPDATE oauth_refresh_tokens
59
65
  SET revoked = ${sql.param(1)}
60
66
  WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
@@ -1,3 +1,3 @@
1
- import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).where("email","=",email).select("id");if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();if(!await ownerQuery.executeTakeFirst())return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await db.primary.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return;const token=await createResetToken(user.id);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).where("email","=",email).select("id");if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();if(!await ownerQuery.executeTakeFirst())return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await db.primary.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return;const token=await createResetToken(user.id);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
2
2
 
3
- This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
3
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeOAuthSubjectAuthorizationState("users",Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
@@ -9,13 +9,18 @@ export declare interface RateLimitEntry {
9
9
  * default {@link MemoryStore} is process-local (fine for single-instance and
10
10
  * dev). On a horizontally-scaled deployment the in-memory store is trivially
11
11
  * bypassed by spreading attempts across instances, so production should swap
12
- * in a shared store via `RateLimiter.useSharedStore()` (cache-backed; becomes
13
- * cluster-wide when the cache driver is Redis) or a custom `useStore()`.
12
+ * in the atomic Redis store via `RateLimiter.useSharedStore()` or provide a
13
+ * custom store whose `recordFailedAttempt` operation is atomic.
14
14
  */
15
15
  export declare interface RateLimiterStore {
16
16
  get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
17
- set: (key: string, entry: RateLimitEntry, ttlMs: number) => Promise<void> | void
18
17
  delete: (key: string) => Promise<void> | void
18
+ recordFailedAttempt: (key: string, now: number, ttlMs: number) => Promise<void> | void
19
+ close?: () => Promise<void> | void
20
+ }
21
+ export declare interface RedisRateLimiterStoreOptions {
22
+ url?: string
23
+ prefix?: string
19
24
  }
20
25
  /** Process-local store — the default. */
21
26
  declare class MemoryStore implements RateLimiterStore {
@@ -24,20 +29,20 @@ declare class MemoryStore implements RateLimiterStore {
24
29
  delete(key: string): void;
25
30
  recordFailedAttempt(key: string, now: number): void;
26
31
  }
27
- /**
28
- * Cache-backed store. Cross-instance when the configured cache driver is
29
- * Redis; otherwise behaves like an in-memory store with TTL eviction. Entries
30
- * carry a TTL so attempts decay automatically — no separate eviction pass.
31
- */
32
- declare class CacheStore implements RateLimiterStore {
32
+ /** Redis-backed store whose Lua update is atomic across workers. */
33
+ export declare class RedisRateLimiterStore implements RateLimiterStore {
34
+ constructor(options?: RedisRateLimiterStoreOptions);
33
35
  get(key: string): Promise<RateLimitEntry | undefined>;
34
36
  set(key: string, entry: RateLimitEntry, ttlMs: number): Promise<void>;
35
37
  delete(key: string): Promise<void>;
38
+ recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
39
+ close(): Promise<void>;
36
40
  }
37
41
  export declare class RateLimiter {
38
42
  static useStore(custom: RateLimiterStore): void;
39
- static useSharedStore(): void;
43
+ static useSharedStore(options?: RedisRateLimiterStoreOptions): void;
40
44
  static useMemoryStore(): void;
45
+ static useConfiguredStore(): void;
41
46
  static isRateLimited(email: string): Promise<boolean>;
42
47
  static recordFailedAttempt(email: string): Promise<void>;
43
48
  static resetAttempts(email: string): Promise<void>;
@@ -1 +1,26 @@
1
- import{HttpError}from"@stacksjs/error-handling";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=MAX_ATTEMPTS){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}class CacheStore{prefix="auth:ratelimit:";async get(key){const{cache}=await import("@stacksjs/cache"),raw=await cache.get(`${this.prefix}${key}`);if(raw==null)return;try{return typeof raw==="string"?JSON.parse(raw):raw}catch{return}}async set(key,entry,ttlMs){const{cache}=await import("@stacksjs/cache");await cache.set(`${this.prefix}${key}`,JSON.stringify(entry),Math.ceil(ttlMs/1000))}async delete(key){const{cache}=await import("@stacksjs/cache");await cache.remove(`${this.prefix}${key}`)}}let store=new MemoryStore;export class RateLimiter{static useStore(custom){store=custom}static useSharedStore(){store=new CacheStore}static useMemoryStore(){store=new MemoryStore}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await store.get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();if(store instanceof MemoryStore){store.recordFailedAttempt(email,now);return}const entry=recordFailure(await store.get(email),now);await store.set(email,entry,LOCKOUT_DURATION)}static async resetAttempts(email){await store.delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
1
+ import{HttpError}from"@stacksjs/error-handling";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=MAX_ATTEMPTS){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}const RECORD_FAILURE_SCRIPT=`
2
+ local attempts = 0
3
+ local locked_until = 0
4
+ local raw = redis.call('GET', KEYS[1])
5
+ if raw then
6
+ local decoded_ok, decoded = pcall(cjson.decode, raw)
7
+ if decoded_ok and type(decoded) == 'table' then
8
+ attempts = tonumber(decoded.attempts) or 0
9
+ locked_until = tonumber(decoded.lockedUntil) or 0
10
+ end
11
+ end
12
+ local now = tonumber(ARGV[1])
13
+ local ttl = tonumber(ARGV[2])
14
+ local maximum = tonumber(ARGV[3])
15
+ if locked_until > 0 and locked_until <= now then
16
+ attempts = 0
17
+ locked_until = 0
18
+ end
19
+ attempts = attempts + 1
20
+ if attempts >= maximum then
21
+ locked_until = now + ttl
22
+ attempts = 0
23
+ end
24
+ redis.call('SET', KEYS[1], cjson.encode({ attempts = attempts, lockedUntil = locked_until }), 'PX', ttl)
25
+ return locked_until
26
+ `;function redisUrl(options,configured){const redis=configured.drivers?.redis;if(options.url)return{url:options.url,prefix:options.prefix??configured.prefix??"stacks"};if(configured.driver!=="redis"||!redis)throw Error("Shared auth rate limiting requires the Redis cache driver or an explicit Redis URL.");if(redis.url)return{url:redis.url,prefix:options.prefix??configured.prefix??"stacks"};const protocol=redis.tls?"rediss":"redis",credentials=redis.username?`${encodeURIComponent(redis.username)}:${encodeURIComponent(redis.password??"")}@`:redis.password?`:${encodeURIComponent(redis.password)}@`:"",database=redis.database==null?"":`/${redis.database}`;return{url:`${protocol}://${credentials}${redis.host??"127.0.0.1"}:${redis.port??6379}${database}`,prefix:options.prefix??configured.prefix??"stacks"}}export class RedisRateLimiterStore{options;clientPromise;constructor(options={}){this.options=options}async client(){if(!this.clientPromise)this.clientPromise=(async()=>{const{cache:configured}=await import("@stacksjs/config"),resolved=redisUrl(this.options,configured),client=new RedisClient(resolved.url);await client.connect();return{client,prefix:resolved.prefix}})().catch((error)=>{this.clientPromise=void 0;throw error});return this.clientPromise}async key(key){const{client,prefix}=await this.client();return{client,key:`${prefix}:auth:ratelimit:${key}`}}async get(key){const resolved=await this.key(key),raw=await resolved.client.get(resolved.key);if(raw==null)return;try{const entry=JSON.parse(raw);if(!Number.isSafeInteger(entry.attempts)||entry.attempts<0||!Number.isSafeInteger(entry.lockedUntil)||entry.lockedUntil<0)return;return{attempts:entry.attempts,lockedUntil:entry.lockedUntil}}catch{return}}async set(key,entry,ttlMs){const resolved=await this.key(key);await resolved.client.send("SET",[resolved.key,JSON.stringify(entry),"PX",String(ttlMs)])}async delete(key){const resolved=await this.key(key);await resolved.client.del(resolved.key)}async recordFailedAttempt(key,now,ttlMs){const resolved=await this.key(key);await resolved.client.send("EVAL",[RECORD_FAILURE_SCRIPT,"1",resolved.key,String(now),String(ttlMs),String(MAX_ATTEMPTS)])}async close(){if(!this.clientPromise)return;const{client}=await this.clientPromise;client.close();this.clientPromise=void 0}}let store;function replaceStore(next){const previous=store;store=next;if(previous&&previous!==next&&previous.close)Promise.resolve(previous.close()).catch(()=>{})}async function configuredStore(){if(store)return store;const{cache}=await import("@stacksjs/config");store??=cache.driver==="redis"?new RedisRateLimiterStore:new MemoryStore;return store}export class RateLimiter{static useStore(custom){replaceStore(custom)}static useSharedStore(options={}){replaceStore(new RedisRateLimiterStore(options))}static useMemoryStore(){replaceStore(new MemoryStore)}static useConfiguredStore(){replaceStore(void 0)}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await(await configuredStore()).get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();await(await configuredStore()).recordFailedAttempt(email,now,LOCKOUT_DURATION)}static async resetAttempts(email){await(await configuredStore()).delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
package/dist/register.js CHANGED
@@ -1 +1 @@
1
- import{config}from"@stacksjs/config";import{db}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials,tokenOptions){const{email,password,name}=credentials;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if(await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst())throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{...tokenOptions,name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
1
+ import{config}from"@stacksjs/config";import{db,getDatabaseDialect,sqlHelpers}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{User}from"@stacksjs/orm";import{makeHash}from"@stacksjs/security";import{Auth}from"./authentication";import{normalizeAuthEmail}from"./credential-user";import{isUniqueViolation}from"./rbac-store-bqb";import{attributeReferral}from"./referrals";function duplicateEmailError(){if(config.auth?.registration?.preventEnumeration===!1)return new HttpError(409,"Email already exists");return new HttpError(422,"Registration could not be completed. Please check your details and try again.")}const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;export async function register(credentials,tokenOptions){const{email:rawEmail,password,name}=credentials,email=typeof rawEmail==="string"?normalizeAuthEmail(rawEmail):rawEmail;if(typeof email!=="string"||email.length>254||!EMAIL_RE.test(email))throw new HttpError(422,"Email address is invalid");if(typeof password!=="string"||password.length<8)throw new HttpError(422,"Password must be at least 8 characters");const hashedPassword=await makeHash(password,{algorithm:"bcrypt"}),userId=await db.transaction(async(rawTrx)=>{const trx=rawTrx,sql=sqlHelpers(getDatabaseDialect());if((await trx.unsafe(`SELECT id FROM users WHERE LOWER(email) = ${sql.param(1)} LIMIT 1`,[email]))[0])throw duplicateEmailError();try{await trx.insertInto("users").values({email,password:hashedPassword,name}).execute()}catch(err){if(isUniqueViolation(err))throw duplicateEmailError();throw err}const created=await trx.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!created)throw Error("Failed to retrieve created user");if(credentials.referralCode)await attributeReferral(Number(created.id),credentials.referralCode,trx);return Number(created.id)}),user=await User.find(userId);if(!user)throw Error("Failed to retrieve created user");const{plainTextToken,refreshToken,expiresIn}=await Auth.createTokenForUser(user,{...tokenOptions,name:"user-auth-token"});return{token:plainTextToken,refreshToken,expiresIn}}
@@ -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";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};
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{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";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=normalizeAuthEmail(email||""),isRateLimited=await RateLimiter.isRateLimited(normalizedEmail),user=await findAuthUserByEmail(normalizedEmail),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()}async function deleteObservedOrphanedSession(sessionId,userId,expiresAt){let cleanup=db.deleteFrom("sessions").where("id","=",sessionId).where("user_id","=",userId);cleanup=expiresAt==null?cleanup.whereNull("expires_at"):cleanup.where("expires_at","=",expiresAt);await cleanup.execute()}async function sessionOwnerExists(userId){return await db.primary.selectFrom("users").where("id","=",userId).select("id").executeTakeFirst()!==void 0}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}const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);return}return await User.find(userId)}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;const userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);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 userId=session.user_id;if(!await sessionOwnerExists(userId)){await deleteObservedOrphanedSession(sessionId,userId,session.expires_at);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};