@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.
- package/README.md +34 -17
- package/dist/authentication.js +1 -1
- package/dist/credential-user.d.ts +5 -0
- package/dist/credential-user.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-codes.js +1 -1
- package/dist/oauth-authorization-endpoint.d.ts +10 -1
- package/dist/oauth-authorization-endpoint.js +1 -1
- package/dist/oauth-authorization-requests.d.ts +11 -2
- package/dist/oauth-authorization-requests.js +20 -6
- package/dist/oauth-authorization.d.ts +2 -0
- package/dist/oauth-authorization.js +1 -1
- package/dist/oauth-client-policy.d.ts +18 -0
- package/dist/oauth-client-policy.js +1 -0
- package/dist/oauth-client-registration.js +10 -10
- package/dist/oauth-consent.d.ts +15 -0
- package/dist/oauth-consent.js +4 -4
- package/dist/oauth-grants.d.ts +2 -0
- package/dist/oauth-grants.js +27 -5
- package/dist/oauth-metadata.d.ts +2 -0
- package/dist/oauth-metadata.js +1 -1
- package/dist/oauth-provider.d.ts +4 -2
- package/dist/oauth-provider.js +1 -1
- package/dist/oauth-pruning.d.ts +10 -0
- package/dist/oauth-pruning.js +9 -0
- package/dist/oauth-token-endpoint.d.ts +5 -1
- package/dist/oauth-token-endpoint.js +1 -1
- package/dist/oauth-token-exchange.d.ts +4 -0
- package/dist/oauth-token-exchange.js +11 -5
- package/dist/password/reset.js +2 -2
- package/dist/rate-limiter.d.ts +15 -10
- package/dist/rate-limiter.js +26 -1
- package/dist/register.js +1 -1
- package/dist/session-auth.js +1 -1
- package/package.json +13 -13
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/oauth-consent.js
CHANGED
|
@@ -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}}
|
|
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)
|
|
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}))}
|
package/dist/oauth-grants.d.ts
CHANGED
|
@@ -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. */
|
package/dist/oauth-grants.js
CHANGED
|
@@ -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)
|
|
2
|
-
SELECT id
|
|
3
|
-
|
|
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])).
|
|
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
|
|
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
|
package/dist/oauth-metadata.d.ts
CHANGED
|
@@ -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 {
|
package/dist/oauth-metadata.js
CHANGED
|
@@ -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()}}
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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",
|
|
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")}
|
|
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{
|
|
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.
|
|
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)}
|
package/dist/password/reset.js
CHANGED
|
@@ -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.")}
|
package/dist/rate-limiter.d.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
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>;
|
package/dist/rate-limiter.js
CHANGED
|
@@ -1 +1,26 @@
|
|
|
1
|
-
import{HttpError}from"@stacksjs/error-handling";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&¤t.lockedUntil>0&¤t.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)}}
|
|
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&¤t.lockedUntil>0&¤t.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.
|
|
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}}
|
package/dist/session-auth.js
CHANGED
|
@@ -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&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.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||"")
|
|
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&¤t.ip!=null&&stored.ip!=null&&String(stored.ip)!==current.ip)return!0;if(enforceUa&¤t.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};
|