@stacksjs/auth 0.75.28 โ†’ 0.75.29

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 CHANGED
@@ -30,23 +30,40 @@ Learn more in the docs.
30
30
 
31
31
  ## OAuth authorization provider
32
32
 
33
- The authorization provider is opt-in through `config/auth.ts`. Enabling it
34
- requires an explicit issuer, registered scopes and resources, and a reviewed
35
- client policy. Provider routes remain unavailable until the complete consent
36
- and token-exchange flow is registered.
37
-
38
- The framework currently exports protocol primitives for incremental provider
39
- integration:
40
-
41
- - `resolveOAuthProviderConfig()` validates and snapshots provider policy.
42
- - `validateOAuthClientRegistration()` validates client-controlled metadata.
43
- - `validateOAuthAuthorizationRequest()` validates exact redirects, scopes,
44
- resources, and S256 PKCE before login or consent.
45
- - `issueAuthorizationCode()` and `withAuthorizationCode()` provide hash-only,
46
- short-lived, atomic, single-use authorization codes.
47
-
48
- These primitives do not turn personal access tokens into delegated OAuth
49
- tokens, and they do not register public endpoints by themselves.
33
+ The authorization provider is disabled by default under `oauthProvider` in
34
+ `config/auth.ts`. Enabling it requires a canonical issuer plus explicit scope
35
+ and resource policy:
36
+
37
+ ```ts
38
+ oauthProvider: {
39
+ enabled: true,
40
+ issuer: env.APP_URL,
41
+ scopes: {
42
+ 'issues:read': { description: 'Read issues', resources: ['bughq'] },
43
+ },
44
+ resources: {
45
+ bughq: { audience: 'https://api.example.com/issues' },
46
+ },
47
+ }
48
+ ```
49
+
50
+ The default auth route bundle then serves:
51
+
52
+ - `GET` and `POST /oauth/authorize` for login, consent, denial, and S256 PKCE
53
+ - `POST /oauth/token` for authorization-code exchange and rotating refresh tokens
54
+ - `POST /oauth/revoke` for client-owned access and refresh token revocation
55
+ - `GET /.well-known/oauth-authorization-server` for RFC 8414 metadata
56
+ - authenticated client registration, editing, secret rotation, and disable routes under `/auth/oauth/clients`
57
+ - authenticated connected-application listing and disconnect routes under `/auth/oauth/connections`
58
+
59
+ The implementation stores only hashes for codes, access tokens, refresh
60
+ tokens, browser authorization state, and confidential client secrets. Client
61
+ redirects use exact matching. Consent and issued credentials remain bound to
62
+ the client, subject, scopes, resources, audiences, and optional workspace.
63
+
64
+ This profile does not implement client credentials, token introspection, or
65
+ OpenID Connect. Do not configure or advertise those capabilities until their
66
+ authentication and authorization policies are implemented.
50
67
 
51
68
  ## ๐Ÿงช Testing
52
69
 
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from './oauth-delegated-access';
15
15
  export * from './oauth-grants';
16
16
  export * from './oauth-metadata';
17
17
  export * from './oauth-provider';
18
+ export * from './oauth-pruning';
18
19
  export * from './oauth-pkce';
19
20
  export * from './oauth-revocation';
20
21
  export * from './oauth-token-exchange';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
1
+ export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
@@ -1,4 +1,4 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isFinite(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be positive.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
1
+ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isSafeInteger(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer of milliseconds.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
2
2
  SELECT id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id
3
3
  FROM oauth_grants
4
4
  WHERE id = ${sql.param(1)} AND revoked_at IS NULL
@@ -1,4 +1,4 @@
1
- import { loadOAuthAuthorizationConsentView } from './oauth-consent';
1
+ import { loadOAuthAuthorizationConsentView, reuseOAuthAuthorizationRequestSession } from './oauth-consent';
2
2
  import type { OAuthAuthorizationConsentView } from './oauth-consent';
3
3
  import type { ResolvedOAuthProviderConfig } from './oauth-provider';
4
4
  /**
@@ -32,11 +32,18 @@ export declare interface HandleOAuthAuthorizationConsentRequestInput {
32
32
  subjectType: string
33
33
  subjectId: number
34
34
  workspaceId?: string | null
35
+ resolveWorkspaceId?: () => Promise<string | null>
35
36
  }
36
37
  export declare interface OAuthAuthorizationPageIdentity {
37
38
  label: string
38
39
  workspaceLabel?: string | null
39
40
  }
41
+ export declare interface OAuthAuthorizationPageSubject {
42
+ type: string
43
+ id: number
44
+ workspaceId?: string | null
45
+ resolveWorkspaceId?: () => Promise<string | null>
46
+ }
40
47
  export declare interface OAuthAuthorizationConsentPageContext {
41
48
  consent: OAuthAuthorizationConsentView
42
49
  signedInIdentity: string
@@ -47,12 +54,14 @@ export declare interface OAuthAuthorizationConsentPageContext {
47
54
  export declare interface OAuthAuthorizationPageDependencies {
48
55
  begin?: typeof beginOAuthAuthorizationRequest
49
56
  loadConsent?: typeof loadOAuthAuthorizationConsentView
57
+ reuseConsent?: typeof reuseOAuthAuthorizationRequestSession
50
58
  render: (view: string, context: OAuthAuthorizationConsentPageContext) => Promise<string>
51
59
  }
52
60
  export declare interface HandleOAuthAuthorizationPageRequestInput {
53
61
  provider: ResolvedOAuthProviderConfig
54
62
  request: Request
55
63
  identity: OAuthAuthorizationPageIdentity | null
64
+ subject?: OAuthAuthorizationPageSubject | null
56
65
  csrfToken?: string
57
66
  dependencies: OAuthAuthorizationPageDependencies
58
67
  }
@@ -1 +1 @@
1
- import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{createOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest}from"./oauth-consent";import{isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await input.request.text())}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);throw error}const browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value):localAuthorizationError(400)}let approved;try{approved=await approveOAuthAuthorizationRequestSession({provider:input.provider,requestId:consent.requestId,browserSessionId,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId:input.workspaceId,authorizationCodeLifetimeMs:input.provider.lifetimes.authorizationCode})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value):localAuthorizationError(400)}
1
+ import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{bindOAuthAuthorizationRequestWorkspace,createOAuthAuthorizationRequestSession,isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest,reuseOAuthAuthorizationRequestSession}from"./oauth-consent";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}if(input.identity&&input.subject&&input.provider.consent.resolveWorkspace){if(!await bindOAuthAuthorizationRequestWorkspace(requestId,browser.id,input.subject.workspaceId??null))return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"})}if(input.identity&&input.subject&&input.provider.consent.rememberFor>0)try{const reused=await(input.dependencies.reuseConsent??reuseOAuthAuthorizationRequestSession)({provider:input.provider,requestId,browserSessionId:browser.id,subjectType:input.subject.type,subjectId:input.subject.id,workspaceId:input.subject.workspaceId,resolveWorkspaceId:input.subject.resolveWorkspaceId});if(reused){if(!reused.ok)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const protocol=oauthAuthorizationConsentResponse(reused.value),headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await input.request.text())}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);throw error}const browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value):localAuthorizationError(400)}let approved;try{approved=await approveOAuthAuthorizationRequestSession({provider:input.provider,requestId:consent.requestId,browserSessionId,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId:input.workspaceId,resolveWorkspaceId:input.resolveWorkspaceId,authorizationCodeLifetimeMs:input.provider.lifetimes.authorizationCode})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value):localAuthorizationError(400)}
@@ -1,10 +1,19 @@
1
- import type { ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
1
+ import { type ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
2
2
  export declare function isOAuthAuthorizationRequestId(value: string): boolean;
3
3
  /** Store one validated authorization request behind an opaque, hashed handle. */
4
4
  export declare function createOAuthAuthorizationRequestSession(request: ValidatedOAuthAuthorizationRequest, browserSessionId: string, lifetimeMs: number): Promise<string>;
5
5
  /** Reload a request only for its original browser and current client policy. */
6
6
  export declare function loadOAuthAuthorizationRequestSession(requestId: string, browserSessionId: string): Promise<ValidatedOAuthAuthorizationRequest | null>;
7
+ /** Bind the opaque request to the workspace displayed on its consent page. */
8
+ export declare function bindOAuthAuthorizationRequestWorkspace(requestId: string, browserSessionId: string, workspaceId: string | null): Promise<boolean>;
7
9
  /** Claim one browser-bound request and complete consent in the same transaction. */
8
- export declare function withOAuthAuthorizationRequestSession<T>(requestId: string, browserSessionId: string, complete: (request: ValidatedOAuthAuthorizationRequest) => Promise<T>): Promise<OAuthAuthorizationRequestSessionResult<T>>;
10
+ export declare function withOAuthAuthorizationRequestSession<T>(requestId: string, browserSessionId: string, complete: (
11
+ request: ValidatedOAuthAuthorizationRequest,
12
+ workspace: OAuthAuthorizationRequestWorkspaceBinding,
13
+ ) => Promise<T>): Promise<OAuthAuthorizationRequestSessionResult<T>>;
14
+ export declare interface OAuthAuthorizationRequestWorkspaceBinding {
15
+ bound: boolean
16
+ id: string | null
17
+ }
9
18
  export type OAuthAuthorizationRequestSessionResult<T> = | { ok: true, value: T }
10
19
  | { ok: false, reason: 'invalid_request' }
@@ -1,11 +1,11 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};export function isOAuthAuthorizationRequestId(value){return REQUEST_ID_PATTERN.test(value)}function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}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 includesAll(allowed,requested){return requested.every((value)=>allowed.includes(value))}function inactive(value){return value===!1||value===0}function requestFromStoredRow(row,now){const clientId=Number(row.client_id);if(!Number.isSafeInteger(clientId)||clientId<=0||row.client_type!=="public"&&row.client_type!=="confidential"||!isValidOAuthRedirectUri(row.redirect_uri)||row.code_challenge_method!=="S256"||!isValidS256CodeChallenge(row.code_challenge)||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=now||row.consumed_at!=null||row.state!=null&&row.state.length>4096)return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes||!resources||!audiences||resources.length!==audiences.length)return null;return{responseType:"code",clientId:String(clientId),clientType:row.client_type,redirectUri:row.redirect_uri,scopes,resources,audiences,state:row.state,codeChallenge:row.code_challenge,codeChallengeMethod:"S256"}}function policyAllowsRequest(row,request){if(!row||String(row.id)!==request.clientId)return!1;const redirectUris=storedValues(row.redirect_uris),grantTypes=storedValues(row.grant_types),scopes=storedValues(row.allowed_scopes),resources=storedValues(row.allowed_resources),method=row.token_endpoint_auth_method;if(!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources)return!1;return row.client_type===request.clientType&&inactive(row.revoked)&&inactive(row.personal_access_client)&&inactive(row.password_client)&&(row.client_type==="public"&&method==="none"&&row.secret==null||row.client_type==="confidential"&&method==="client_secret_basic"&&Boolean(row.secret))&&redirectUris.includes(request.redirectUri)&&row.redirect===redirectUris[0]&&new Set(redirectUris).size===redirectUris.length&&new Set(grantTypes).size===grantTypes.length&&new Set(scopes).size===scopes.length&&new Set(resources).size===resources.length&&redirectUris.every(isValidOAuthRedirectUri)&&includesAll(scopes,request.scopes)&&includesAll(resources,request.resources)}export async function createOAuthAuthorizationRequestSession(request,browserSessionId,lifetimeMs){const clientId=Number(request.clientId);if(!Number.isSafeInteger(clientId)||clientId<=0)throw TypeError("OAuth authorization request client identifier is invalid.");if(!validBrowserSession(browserSessionId))throw TypeError("OAuth authorization request requires a valid browser session identifier.");if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth authorization request lifetime must be a positive safe integer.");if(request.state!=null&&request.state.length>4096)throw TypeError("OAuth authorization request state exceeds 4096 characters.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
1
+ import{isValidOAuthRedirectUri,isValidOAuthState}from"./oauth-authorization";import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};export function isOAuthAuthorizationRequestId(value){return REQUEST_ID_PATTERN.test(value)}function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}function validValues(values){return values.every((value)=>value.length>0)&&new Set(values).size===values.length}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")&&validValues(parsed)?parsed:null}catch{return null}}function includesAll(allowed,requested){return requested.every((value)=>allowed.includes(value))}function inactive(value){return value===!1||value===0}function workspaceBindingFromStoredRow(row){const bound=row.workspace_bound===!0||row.workspace_bound===1;if(!bound&&!inactive(row.workspace_bound))return null;if(row.workspace_id!==null&&(!row.workspace_id||row.workspace_id.length>255||/[\u0000-\u001F\u007F]/.test(row.workspace_id)))return null;if(!bound&&row.workspace_id!==null)return null;return{bound,id:row.workspace_id}}function requestFromStoredRow(row,now){const clientId=Number(row.client_id);if(!Number.isSafeInteger(clientId)||clientId<=0||row.client_type!=="public"&&row.client_type!=="confidential"||!isValidOAuthRedirectUri(row.redirect_uri)||row.code_challenge_method!=="S256"||!isValidS256CodeChallenge(row.code_challenge)||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=now||row.consumed_at!=null||row.state!=null&&!isValidOAuthState(row.state))return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes?.length||!resources||!audiences||resources.length!==audiences.length)return null;return{responseType:"code",clientId:String(clientId),clientType:row.client_type,redirectUri:row.redirect_uri,scopes,resources,audiences,state:row.state,codeChallenge:row.code_challenge,codeChallengeMethod:"S256"}}function policyAllowsRequest(row,request){if(!row||String(row.id)!==request.clientId)return!1;const redirectUris=storedValues(row.redirect_uris),grantTypes=storedValues(row.grant_types),scopes=storedValues(row.allowed_scopes),resources=storedValues(row.allowed_resources),method=row.token_endpoint_auth_method;if(!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources)return!1;return row.client_type===request.clientType&&inactive(row.revoked)&&inactive(row.personal_access_client)&&inactive(row.password_client)&&(row.client_type==="public"&&method==="none"&&row.secret==null||row.client_type==="confidential"&&method==="client_secret_basic"&&Boolean(row.secret))&&redirectUris.includes(request.redirectUri)&&row.redirect===redirectUris[0]&&new Set(redirectUris).size===redirectUris.length&&new Set(grantTypes).size===grantTypes.length&&new Set(scopes).size===scopes.length&&new Set(resources).size===resources.length&&redirectUris.every(isValidOAuthRedirectUri)&&includesAll(scopes,request.scopes)&&includesAll(resources,request.resources)}export async function createOAuthAuthorizationRequestSession(request,browserSessionId,lifetimeMs){const clientId=Number(request.clientId);if(!Number.isSafeInteger(clientId)||clientId<=0)throw TypeError("OAuth authorization request client identifier is invalid.");if(!validBrowserSession(browserSessionId))throw TypeError("OAuth authorization request requires a valid browser session identifier.");if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth authorization request lifetime must be a positive safe integer.");if(request.state!=null&&!isValidOAuthState(request.state))throw TypeError("OAuth authorization request state must contain 1 to 4096 visible ASCII characters.");if(!request.scopes.length||!validValues(request.scopes))throw TypeError("OAuth authorization request scopes must contain unique non-empty values.");if(!validValues(request.resources)||!validValues(request.audiences)||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request resources and audiences must contain unique paired values.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
2
2
  INSERT INTO oauth_authorization_requests (
3
3
  request_hash, browser_session_hash, client_id, client_type, redirect_uri,
4
4
  scopes, resources, audiences, state, code_challenge, code_challenge_method,
5
5
  expires_at, created_at
6
6
  ) VALUES (${values.sql})
7
7
  `,values.values);markContextWrote();return id}export async function loadOAuthAuthorizationRequestSession(requestId,browserSessionId){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
8
- SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
8
+ SELECT client_id, client_type, redirect_uri, scopes, resources, audiences, workspace_id, workspace_bound,
9
9
  state, code_challenge, code_challenge_method, expires_at, consumed_at
10
10
  FROM oauth_authorization_requests
11
11
  WHERE request_hash = ${sql.param(1)}
@@ -13,14 +13,28 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
13
13
  AND consumed_at IS NULL
14
14
  AND expires_at > ${sql.param(3)}
15
15
  LIMIT 1
16
- `,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return invalidRequest;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=new Date,nowSql=sqlDateTime(now),rows=await trx.unsafe(`
17
- SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
16
+ `,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),workspace=workspaceBindingFromStoredRow(row),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!workspace||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function bindOAuthAuthorizationRequestWorkspace(requestId,browserSessionId,workspaceId){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId)||workspaceId!==null&&(!workspaceId||workspaceId.length>255||/[\u0000-\u001F\u007F]/.test(workspaceId)))return!1;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=sqlDateTime(new Date),row=(await trx.unsafe(`
17
+ SELECT workspace_id, workspace_bound, expires_at, consumed_at
18
+ FROM oauth_authorization_requests
19
+ WHERE request_hash = ${sql.param(1)}
20
+ AND browser_session_hash = ${sql.param(2)}
21
+ LIMIT 1${requestLock}
22
+ `,[hash,sessionHash]))[0],binding=row?workspaceBindingFromStoredRow(row):null;if(!row||!binding||row.consumed_at!=null||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now())return{matches:!1,wrote:!1};if(binding.bound)return{matches:binding.id===workspaceId,wrote:!1};const updated=await trx.unsafe(`
23
+ UPDATE oauth_authorization_requests
24
+ SET workspace_id = ${sql.param(1)}, workspace_bound = ${sql.boolTrue}
25
+ WHERE request_hash = ${sql.param(2)}
26
+ AND browser_session_hash = ${sql.param(3)}
27
+ AND workspace_bound = ${sql.boolFalse}
28
+ AND consumed_at IS NULL
29
+ AND expires_at > ${sql.param(4)}
30
+ `,[workspaceId,hash,sessionHash,now]),wrote=mutationCount(updated)===1;return{matches:wrote,wrote}});if(result.wrote)markContextWrote();return result.matches}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return invalidRequest;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=new Date,nowSql=sqlDateTime(now),rows=await trx.unsafe(`
31
+ SELECT client_id, client_type, redirect_uri, scopes, resources, audiences, workspace_id, workspace_bound,
18
32
  state, code_challenge, code_challenge_method, expires_at, consumed_at
19
33
  FROM oauth_authorization_requests
20
34
  WHERE request_hash = ${sql.param(1)}
21
35
  AND browser_session_hash = ${sql.param(2)}
22
36
  LIMIT 1${requestLock}
23
- `,[hash,sessionHash]),request=rows[0]?requestFromStoredRow(rows[0],now.getTime()):null;if(!request)return invalidRequest;const clients=await trx.unsafe(`
37
+ `,[hash,sessionHash]),request=rows[0]?requestFromStoredRow(rows[0],now.getTime()):null,workspace=rows[0]?workspaceBindingFromStoredRow(rows[0]):null;if(!request||!workspace)return invalidRequest;const clients=await trx.unsafe(`
24
38
  SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
25
39
  token_endpoint_auth_method, allowed_scopes, allowed_resources,
26
40
  personal_access_client, password_client, revoked
@@ -34,4 +48,4 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
34
48
  AND browser_session_hash = ${sql.param(3)}
35
49
  AND consumed_at IS NULL
36
50
  AND expires_at > ${sql.param(4)}
37
- `,[nowSql,hash,sessionHash,nowSql]);if(mutationCount(claimed)!==1)return invalidRequest;return{ok:!0,value:await complete(request)}});if(result.ok)markContextWrote();return result}
51
+ `,[nowSql,hash,sessionHash,nowSql]);if(mutationCount(claimed)!==1)return invalidRequest;return{ok:!0,value:await complete(request,workspace)}});if(result.ok)markContextWrote();return result}
@@ -3,6 +3,8 @@ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
3
3
  export declare function parseOAuthAuthorizationRequest(query: string | URLSearchParams): OAuthAuthorizationRequestInput;
4
4
  /** Whether a registered callback is safe for exact redirect matching. */
5
5
  export declare function isValidOAuthRedirectUri(value: string): boolean;
6
+ /** Whether an optional OAuth state value is safe to persist and reflect. */
7
+ export declare function isValidOAuthState(value: string): boolean;
6
8
  /** Recheck provider-owned policy immediately before consent creates a grant. */
7
9
  export declare function assertOAuthAuthorizationProviderPolicy(provider: ResolvedOAuthProviderConfig, request: ValidatedOAuthAuthorizationRequest): void;
8
10
  /**
@@ -1 +1 @@
1
- import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}function scalarParameter(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth authorization parameter must not be repeated: ${name}`);if(required&&(!values.length||values[0]===""))reject("invalid_request",`OAuth authorization parameter is required: ${name}`);return values[0]}export function parseOAuthAuthorizationRequest(query){const encoded=typeof query==="string"?query.replace(/^\?/,""):query.toString();if(encoded.length>8192)reject("invalid_request","OAuth authorization request is too large.");const params=typeof query==="string"?new URLSearchParams(encoded):query,responseType=scalarParameter(params,"response_type",!0),clientId=scalarParameter(params,"client_id",!0),redirectUri=scalarParameter(params,"redirect_uri",!0),scope=scalarParameter(params,"scope"),state=scalarParameter(params,"state"),codeChallenge=scalarParameter(params,"code_challenge",!0),codeChallengeMethod=scalarParameter(params,"code_challenge_method",!0),resources=params.getAll("resource");return{responseType,clientId,redirectUri,codeChallenge,codeChallengeMethod,...scope===void 0?{}:{scope},...resources.length?{resource:resources}:{},...state===void 0?{}:{state}}}export function isValidOAuthRedirectUri(value){if(value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}function requestedScopes(value,redirectUri,state){if(value==null||value==="")return[];const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function assertOAuthAuthorizationProviderPolicy(provider,request){if(!provider.clientTypes.includes(request.clientType))reject("unauthorized_client","OAuth client type is no longer enabled by this provider.",request.redirectUri,request.state);for(const scope of request.scopes)if(!provider.scopes[scope])reject("invalid_scope",`OAuth scope is no longer registered by this provider: ${scope}`,request.redirectUri,request.state);if(request.resources.length!==request.audiences.length)reject("invalid_target","OAuth resource bindings are invalid.",request.redirectUri,request.state);for(const[index,resource]of request.resources.entries())if(provider.resources[resource]?.audience!==request.audiences[index])reject("invalid_target",`OAuth resource is no longer registered by this provider: ${resource}`,request.redirectUri,request.state);for(const scope of request.scopes){const allowedResources=provider.scopes[scope]?.resources;if(!allowedResources?.length)continue;if(!request.resources.length||request.resources.some((resource)=>!allowedResources.includes(resource)))reject("invalid_target",`OAuth scope is not valid for the requested resource: ${scope}`,request.redirectUri,request.state)}}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!client.scopes.includes(scope))reject("invalid_scope",`OAuth scope is not registered for this client: ${scope}`,redirectUri,state);const audiences=requestedAudiences(input.resource,redirectUri,state),resources=[];for(const audience of audiences){const matchingResources=Object.entries(provider.resources).filter(([,resource])=>resource.audience===audience).map(([key])=>key);if(matchingResources.length!==1||!client.resources.includes(matchingResources[0]))reject("invalid_target",`OAuth resource is not registered for this client: ${audience}`,redirectUri,state);resources.push(matchingResources[0])}const validated={responseType:"code",clientId:input.clientId,clientType:client.type,redirectUri,scopes,resources,audiences,state,codeChallenge:input.codeChallenge,codeChallengeMethod:"S256"};assertOAuthAuthorizationProviderPolicy(provider,validated);return validated}
1
+ import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}function scalarParameter(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth authorization parameter must not be repeated: ${name}`);if(required&&(!values.length||values[0]===""))reject("invalid_request",`OAuth authorization parameter is required: ${name}`);return values[0]}export function parseOAuthAuthorizationRequest(query){const encoded=typeof query==="string"?query.replace(/^\?/,""):query.toString();if(encoded.length>8192)reject("invalid_request","OAuth authorization request is too large.");const params=typeof query==="string"?new URLSearchParams(encoded):query,responseType=scalarParameter(params,"response_type",!0),clientId=scalarParameter(params,"client_id",!0),redirectUri=scalarParameter(params,"redirect_uri",!0),scope=scalarParameter(params,"scope"),state=scalarParameter(params,"state"),codeChallenge=scalarParameter(params,"code_challenge",!0),codeChallengeMethod=scalarParameter(params,"code_challenge_method",!0),resources=params.getAll("resource");return{responseType,clientId,redirectUri,codeChallenge,codeChallengeMethod,...scope===void 0?{}:{scope},...resources.length?{resource:resources}:{},...state===void 0?{}:{state}}}export function isValidOAuthRedirectUri(value){if(value.length>2048||value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}export function isValidOAuthState(value){return/^[\x20-\x7E]{1,4096}$/.test(value)}function requestedScopes(value,redirectUri,state){if(value==null||value==="")reject("invalid_scope","OAuth authorization requests require at least one scope.",redirectUri,state);const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function assertOAuthAuthorizationProviderPolicy(provider,request){if(!provider.clientTypes.includes(request.clientType))reject("unauthorized_client","OAuth client type is no longer enabled by this provider.",request.redirectUri,request.state);for(const scope of request.scopes)if(!provider.scopes[scope])reject("invalid_scope",`OAuth scope is no longer registered by this provider: ${scope}`,request.redirectUri,request.state);if(request.resources.length!==request.audiences.length)reject("invalid_target","OAuth resource bindings are invalid.",request.redirectUri,request.state);for(const[index,resource]of request.resources.entries())if(provider.resources[resource]?.audience!==request.audiences[index])reject("invalid_target",`OAuth resource is no longer registered by this provider: ${resource}`,request.redirectUri,request.state);for(const scope of request.scopes){const allowedResources=provider.scopes[scope]?.resources;if(!allowedResources?.length)continue;if(!request.resources.length||request.resources.some((resource)=>!allowedResources.includes(resource)))reject("invalid_target",`OAuth scope is not valid for the requested resource: ${scope}`,request.redirectUri,request.state)}}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(state!==null&&!isValidOAuthState(state))reject("invalid_request","OAuth authorization request state must contain 1 to 4096 visible ASCII characters.",redirectUri);if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!client.scopes.includes(scope))reject("invalid_scope",`OAuth scope is not registered for this client: ${scope}`,redirectUri,state);const audiences=requestedAudiences(input.resource,redirectUri,state),resources=[];for(const audience of audiences){const matchingResources=Object.entries(provider.resources).filter(([,resource])=>resource.audience===audience).map(([key])=>key);if(matchingResources.length!==1||!client.resources.includes(matchingResources[0]))reject("invalid_target",`OAuth resource is not registered for this client: ${audience}`,redirectUri,state);resources.push(matchingResources[0])}const validated={responseType:"code",clientId:input.clientId,clientType:client.type,redirectUri,scopes,resources,audiences,state,codeChallenge:input.codeChallenge,codeChallengeMethod:"S256"};assertOAuthAuthorizationProviderPolicy(provider,validated);return validated}
@@ -0,0 +1,18 @@
1
+ import type { OAuthAuthorizationClientRegistration } from './oauth-authorization';
2
+ export declare function storedOAuthValues(value: string | null): string[] | null;
3
+ export declare function isInactiveOAuthFlag(value: boolean | number): boolean;
4
+ export declare function oauthAuthorizationClientFromStored(row: StoredOAuthAuthorizationClient | undefined, id: number): OAuthAuthorizationClientRegistration | null;
5
+ export declare interface StoredOAuthAuthorizationClient {
6
+ id: number | string
7
+ secret: string | null
8
+ redirect: string
9
+ client_type: string | null
10
+ redirect_uris: string | null
11
+ grant_types: string | null
12
+ token_endpoint_auth_method: string | null
13
+ allowed_scopes: string | null
14
+ allowed_resources: string | null
15
+ personal_access_client: boolean | number
16
+ password_client: boolean | number
17
+ revoked: boolean | number
18
+ }
@@ -0,0 +1 @@
1
+ import{isValidOAuthRedirectUri}from"./oauth-authorization";export function storedOAuthValues(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}}export function isInactiveOAuthFlag(value){return value===!1||value===0}function storedOAuthFlag(value){if(isInactiveOAuthFlag(value))return!1;if(value===!0||value===1)return!0;return null}export function oauthAuthorizationClientFromStored(row,id){const redirectUris=storedOAuthValues(row?.redirect_uris??null),grantTypes=storedOAuthValues(row?.grant_types??null),scopes=storedOAuthValues(row?.allowed_scopes??null),resources=storedOAuthValues(row?.allowed_resources??null),revoked=row?storedOAuthFlag(row.revoked):null,type=row?.client_type,method=row?.token_endpoint_auth_method;if(!row||String(row.id)!==String(id)||type!=="public"&&type!=="confidential"||method!=="none"&&method!=="client_secret_basic"||(type==="public"?method!=="none"||row.secret!=null:method!=="client_secret_basic"||!row.secret)||!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources||new Set(redirectUris).size!==redirectUris.length||new Set(grantTypes).size!==grantTypes.length||new Set(scopes).size!==scopes.length||new Set(resources).size!==resources.length||redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri))||row.redirect!==redirectUris[0]||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}
@@ -1,4 +1,4 @@
1
- import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const redirectUris=explicitUnique("redirect URI",input.redirectUris,{required:!0});if(redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri)))reject("OAuth client redirect URIs must be exact HTTPS URLs or exact loopback HTTP URLs, without credentials, fragments, or wildcards.");const grantTypes=explicitUnique("grant type",input.grantTypes,{required:!0});if(!grantTypes.includes("authorization_code"))reject("OAuth client grant types must include authorization_code.");for(const grantType of grantTypes)if(!provider.grantTypes.includes(grantType))reject(`OAuth client requested an unsupported grant type: ${grantType}`);const scopes=explicitUnique("scope",input.scopes,{required:!0});for(const scope of scopes)if(!provider.scopes[scope])reject(`OAuth client requested an unregistered scope: ${scope}`);const resources=explicitUnique("resource",input.resources);for(const resource of resources)if(!provider.resources[resource])reject(`OAuth client requested an unregistered resource: ${resource}`);for(const scope of scopes){const allowedResources=provider.scopes[scope]?.resources;if(allowedResources?.length&&!resources.some((resource)=>allowedResources.includes(resource)))reject(`OAuth client scope requires a registered resource: ${scope}`)}return{name,type:input.type,tokenEndpointAuthMethod:input.tokenEndpointAuthMethod,redirectUris,grantTypes,scopes,resources,requiresSecret:input.type==="confidential"}}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 inactive(value){return value===!1||value===0}function sameValues(left,right){return left.length===right.length&&left.every((value,index)=>value===right[index])}async function revokeOAuthClientAuthorizationState(trx,sql,clientId,now){const revoked=sql.isPostgres?!0:1;await trx.unsafe(`
1
+ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isInactiveOAuthFlag,oauthAuthorizationClientFromStored,storedOAuthValues}from"./oauth-client-policy";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const redirectUris=explicitUnique("redirect URI",input.redirectUris,{required:!0});if(redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri)))reject("OAuth client redirect URIs must be exact HTTPS URLs or exact loopback HTTP URLs, without credentials, fragments, or wildcards.");const grantTypes=explicitUnique("grant type",input.grantTypes,{required:!0});if(!grantTypes.includes("authorization_code"))reject("OAuth client grant types must include authorization_code.");for(const grantType of grantTypes)if(!provider.grantTypes.includes(grantType))reject(`OAuth client requested an unsupported grant type: ${grantType}`);const scopes=explicitUnique("scope",input.scopes,{required:!0});for(const scope of scopes)if(!provider.scopes[scope])reject(`OAuth client requested an unregistered scope: ${scope}`);const resources=explicitUnique("resource",input.resources);for(const resource of resources)if(!provider.resources[resource])reject(`OAuth client requested an unregistered resource: ${resource}`);for(const scope of scopes){const allowedResources=provider.scopes[scope]?.resources;if(allowedResources?.length&&!resources.some((resource)=>allowedResources.includes(resource)))reject(`OAuth client scope requires a registered resource: ${scope}`)}return{name,type:input.type,tokenEndpointAuthMethod:input.tokenEndpointAuthMethod,redirectUris,grantTypes,scopes,resources,requiresSecret:input.type==="confidential"}}function sameValues(left,right){return left.length===right.length&&left.every((value,index)=>value===right[index])}async function revokeOAuthClientAuthorizationState(trx,sql,clientId,now){const revoked=sql.isPostgres?!0:1;await trx.unsafe(`
2
2
  UPDATE oauth_grants
3
3
  SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
4
4
  WHERE client_id = ${sql.param(3)} AND revoked_at IS NULL
@@ -20,26 +20,26 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
20
20
  UPDATE oauth_access_tokens
21
21
  SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
22
22
  WHERE oauth_client_id = ${sql.param(3)}
23
- `,[revoked,now,clientId])}function storedFlag(value){if(value===!1||value===0)return!1;if(value===!0||value===1)return!0;return null}function authorizationClientFromStored(row,id){const redirectUris=storedValues(row?.redirect_uris??null),grantTypes=storedValues(row?.grant_types??null),scopes=storedValues(row?.allowed_scopes??null),resources=storedValues(row?.allowed_resources??null),revoked=row?storedFlag(row.revoked):null,type=row?.client_type,method=row?.token_endpoint_auth_method;if(!row||String(row.id)!==String(id)||type!=="public"&&type!=="confidential"||method!=="none"&&method!=="client_secret_basic"||(type==="public"?method!=="none"||row.secret!=null:method!=="client_secret_basic"||!row.secret)||!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources||new Set(redirectUris).size!==redirectUris.length||new Set(grantTypes).size!==grantTypes.length||new Set(scopes).size!==scopes.length||new Set(resources).size!==resources.length||redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri))||row.redirect!==redirectUris[0]||!inactive(row.personal_access_client)||!inactive(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}async function storedAuthorizationClient(clientId){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
23
+ `,[revoked,now,clientId])}async function storedAuthorizationClient(clientId){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
24
24
  SELECT id, name, secret, redirect, client_type, redirect_uris, grant_types,
25
25
  token_endpoint_auth_method, allowed_scopes, allowed_resources,
26
26
  personal_access_client, password_client, revoked
27
27
  FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
28
- `,[id]))[0],client=authorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function loadOAuthAuthorizationClientDetails(clientId){const stored=await storedAuthorizationClient(clientId),name=typeof stored?.row.name==="string"?stored.row.name.trim():"";if(!stored||!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))return null;return{...stored.client,name}}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
28
+ `,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function loadOAuthAuthorizationClientDetails(clientId){const stored=await storedAuthorizationClient(clientId),name=typeof stored?.row.name==="string"?stored.row.name.trim():"";if(!stored||!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))return null;return{...stored.client,name}}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
29
29
  SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
30
30
  token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
31
31
  password_client, revoked, created_at
32
32
  FROM oauth_clients
33
33
  WHERE user_id = ${sql.param(1)}
34
34
  ORDER BY created_at DESC, id DESC
35
- `,[ownerId])).flatMap((row)=>{const id=Number(row.id),storedOwnerId=Number(row.user_id),client=authorizationClientFromStored(row,id),createdAt=parseSqlDateTime(row.created_at);if(!client||!Number.isSafeInteger(storedOwnerId)||storedOwnerId!==ownerId||!createdAt)return[];return[{id,ownerId,name:row.name,type:client.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:client.revoked,createdAt}]})}export async function updateOAuthClient(provider,ownerId,clientId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,row=(await trx.unsafe(`
35
+ `,[ownerId])).flatMap((row)=>{const id=Number(row.id),storedOwnerId=Number(row.user_id),client=oauthAuthorizationClientFromStored(row,id),createdAt=parseSqlDateTime(row.created_at);if(!client||!Number.isSafeInteger(storedOwnerId)||storedOwnerId!==ownerId||!createdAt)return[];return[{id,ownerId,name:row.name,type:client.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:client.revoked,createdAt}]})}export async function updateOAuthClient(provider,ownerId,clientId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,row=(await trx.unsafe(`
36
36
  SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
37
37
  token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
38
38
  password_client, revoked, created_at
39
39
  FROM oauth_clients
40
40
  WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
41
41
  LIMIT 1${lock}
42
- `,[clientId,ownerId]))[0],current=authorizationClientFromStored(row,clientId),createdAt=row?parseSqlDateTime(row.created_at):null;if(!row||!current||current.revoked||!createdAt)return null;const validated=validateOAuthClientRegistration(provider,{...input,type:current.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method}),policyChanged=!sameValues(current.redirectUris,validated.redirectUris)||!sameValues(current.grantTypes,validated.grantTypes)||!sameValues(current.scopes,validated.scopes)||!sameValues(current.resources,validated.resources),changed=policyChanged||row.name!==validated.name,client={id:clientId,ownerId,name:validated.name,type:current.type,tokenEndpointAuthMethod:validated.tokenEndpointAuthMethod,redirectUris:validated.redirectUris,grantTypes:validated.grantTypes,scopes:validated.scopes,resources:validated.resources,revoked:!1,createdAt};if(!changed)return{changed:!1,client};const active=sql.isPostgres?!1:0,now=sqlDateTime(new Date),updated=await trx.unsafe(`
42
+ `,[clientId,ownerId]))[0],current=oauthAuthorizationClientFromStored(row,clientId),createdAt=row?parseSqlDateTime(row.created_at):null;if(!row||!current||current.revoked||!createdAt)return null;const validated=validateOAuthClientRegistration(provider,{...input,type:current.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method}),policyChanged=!sameValues(current.redirectUris,validated.redirectUris)||!sameValues(current.grantTypes,validated.grantTypes)||!sameValues(current.scopes,validated.scopes)||!sameValues(current.resources,validated.resources),changed=policyChanged||row.name!==validated.name,client={id:clientId,ownerId,name:validated.name,type:current.type,tokenEndpointAuthMethod:validated.tokenEndpointAuthMethod,redirectUris:validated.redirectUris,grantTypes:validated.grantTypes,scopes:validated.scopes,resources:validated.resources,revoked:!1,createdAt};if(!changed)return{changed:!1,client};const active=sql.isPostgres?!1:0,now=sqlDateTime(new Date),updated=await trx.unsafe(`
43
43
  UPDATE oauth_clients
44
44
  SET name = ${sql.param(1)}, redirect = ${sql.param(2)}, redirect_uris = ${sql.param(3)},
45
45
  grant_types = ${sql.param(4)}, allowed_scopes = ${sql.param(5)}, allowed_resources = ${sql.param(6)},
@@ -50,28 +50,28 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
50
50
  token_endpoint_auth_method, allowed_scopes, allowed_resources,
51
51
  personal_access_client, password_client, revoked
52
52
  FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1${lock}
53
- `,[id]))[0],client=authorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0],client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
53
+ `,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0],client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
54
54
  INSERT INTO oauth_clients (
55
55
  user_id, name, secret, provider, redirect, client_type, redirect_uris, grant_types,
56
56
  token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
57
57
  password_client, revoked, created_at
58
58
  ) VALUES (${values.sql})${returning}
59
- `,values.values),idRows=sql.isMysql?await trx.unsafe("SELECT LAST_INSERT_ID() AS id"):inserted,id=Number(idRows[0]?.id);if(!Number.isSafeInteger(id)||id<=0)throw Error("Failed to resolve the registered OAuth client identifier.");const row=(await trx.unsafe(`SELECT * FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedValues(row.allowed_resources))!==JSON.stringify(client.resources)||!inactive(row.personal_access_client)||!inactive(row.password_client)||!inactive(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
59
+ `,values.values),idRows=sql.isMysql?await trx.unsafe("SELECT LAST_INSERT_ID() AS id"):inserted,id=Number(idRows[0]?.id);if(!Number.isSafeInteger(id)||id<=0)throw Error("Failed to resolve the registered OAuth client identifier.");const row=(await trx.unsafe(`SELECT * FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
60
60
  SELECT client_type, token_endpoint_auth_method, revoked
61
61
  FROM oauth_clients
62
62
  WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
63
63
  LIMIT 1${lock}
64
- `,[clientId,ownerId]))[0];if(!client||client.client_type!=="confidential"||client.token_endpoint_auth_method!=="client_secret_basic"||!inactive(client.revoked))return!1;const active=sql.isPostgres?!1:0,changed=await trx.unsafe(`
64
+ `,[clientId,ownerId]))[0];if(!client||client.client_type!=="confidential"||client.token_endpoint_auth_method!=="client_secret_basic"||!isInactiveOAuthFlag(client.revoked))return!1;const active=sql.isPostgres?!1:0,now=sqlDateTime(new Date),changed=await trx.unsafe(`
65
65
  UPDATE oauth_clients
66
66
  SET secret = ${sql.param(1)}, updated_at = ${sql.param(2)}
67
67
  WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)}
68
68
  AND client_type = ${sql.param(5)} AND token_endpoint_auth_method = ${sql.param(6)}
69
69
  AND revoked = ${sql.param(7)}
70
- `,[storedSecret,sqlDateTime(new Date),clientId,ownerId,"confidential","client_secret_basic",active]);return mutationCount(changed)===1}))return null;markContextWrote();return plainTextSecret}export async function disableOAuthClient(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disabled=await db.transaction(async(rawTrx)=>{const trx=rawTrx,rows=await trx.unsafe(`
70
+ `,[storedSecret,now,clientId,ownerId,"confidential","client_secret_basic",active]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0}))return null;markContextWrote();return plainTextSecret}export async function disableOAuthClient(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disabled=await db.transaction(async(rawTrx)=>{const trx=rawTrx,rows=await trx.unsafe(`
71
71
  SELECT id, revoked FROM oauth_clients
72
72
  WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
73
73
  LIMIT 1${lock}
74
- `,[clientId,ownerId]);if(!rows[0]||!inactive(rows[0].revoked))return!1;const now=sqlDateTime(new Date),revoked=sql.isPostgres?!0:1,active=sql.isPostgres?!1:0,changed=await trx.unsafe(`
74
+ `,[clientId,ownerId]);if(!rows[0]||!isInactiveOAuthFlag(rows[0].revoked))return!1;const now=sqlDateTime(new Date),revoked=sql.isPostgres?!0:1,active=sql.isPostgres?!1:0,changed=await trx.unsafe(`
75
75
  UPDATE oauth_clients
76
76
  SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
77
77
  WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)} AND revoked = ${sql.param(5)}
@@ -7,6 +7,8 @@ export declare function parseOAuthAuthorizationConsentRequest(body: string | URL
7
7
  export declare function loadOAuthAuthorizationConsentView(input: LoadOAuthAuthorizationConsentViewInput): Promise<OAuthAuthorizationConsentView | null>;
8
8
  /** Whether a recent active grant already covers the requested consent. */
9
9
  export declare function hasReusableOAuthConsent(input: ReusableOAuthConsentInput): Promise<boolean>;
10
+ /** Reuse one exact remembered grant without creating a duplicate connection. */
11
+ export declare function reuseOAuthAuthorizationRequestSession(input: ReuseOAuthAuthorizationRequestSessionInput): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationConsentResult> | null>;
10
12
  /** Atomically turn one authenticated browser approval into a grant and code. */
11
13
  export declare function approveOAuthAuthorizationRequestSession(input: ApproveOAuthAuthorizationRequestSessionInput): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationConsentResult>>;
12
14
  /** Consume one browser-bound denial without creating a grant or authorization code. */
@@ -18,6 +20,7 @@ export declare interface ApproveOAuthAuthorizationRequestSessionInput {
18
20
  subjectType: string
19
21
  subjectId: number
20
22
  workspaceId?: string | null
23
+ resolveWorkspaceId?: () => Promise<string | null>
21
24
  authorizationCodeLifetimeMs: number
22
25
  }
23
26
  export declare interface OAuthAuthorizationConsentResult {
@@ -38,6 +41,15 @@ export declare interface ReusableOAuthConsentInput {
38
41
  workspaceId?: string | null
39
42
  rememberForMs: number
40
43
  }
44
+ export declare interface ReuseOAuthAuthorizationRequestSessionInput {
45
+ provider: ResolvedOAuthProviderConfig
46
+ requestId: string
47
+ browserSessionId: string
48
+ subjectType: string
49
+ subjectId: number
50
+ workspaceId?: string | null
51
+ resolveWorkspaceId?: () => Promise<string | null>
52
+ }
41
53
  export declare interface LoadOAuthAuthorizationConsentViewInput {
42
54
  provider: ResolvedOAuthProviderConfig
43
55
  requestId: string
@@ -68,3 +80,6 @@ export declare class OAuthAuthorizationConsentRequestError extends Error {
68
80
  readonly code: 'invalid_request';
69
81
  constructor(message: string);
70
82
  }
83
+ declare class NoReusableOAuthConsentError extends Error {
84
+
85
+ }
@@ -1,5 +1,5 @@
1
- import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}export async function hasReusableOAuthConsent(input){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return!1;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return!1;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return!1;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`;return(await db.primary.unsafe(`
2
- SELECT g.scopes, g.resources, g.audiences, g.created_at
1
+ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}class NoReusableOAuthConsentError extends Error{}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}async function reusableOAuthConsentGrantId(input,options={}){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return null;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return null;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return null;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`,lock=options.lock?sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"":"",rows=await db.primary.unsafe(`
2
+ SELECT g.id, g.scopes, g.resources, g.audiences, g.created_at
3
3
  FROM oauth_grants g
4
4
  JOIN oauth_clients c ON c.id = g.client_id AND c.revoked = ${sql.boolFalse}
5
5
  WHERE g.client_id = ${sql.param(1)}
@@ -8,5 +8,5 @@ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,val
8
8
  AND g.revoked_at IS NULL
9
9
  AND g.created_at >= ${sql.param(4)}
10
10
  AND ${workspacePredicate}
11
- ORDER BY g.created_at DESC, g.id DESC
12
- `,params)).some((row)=>{const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);return(parseSqlDateTime(row.created_at)?.getTime()??0)>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)})}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId:input.workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
11
+ ORDER BY g.created_at DESC, g.id DESC${lock}
12
+ `,params);for(const row of rows){const grantId=String(row.id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at)?.getTime()??0;if(!(/^[a-f0-9]{32}$/.test(grantId)&&createdAt>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)))continue;if(options.exact&&(JSON.stringify(scopes)!==JSON.stringify(input.request.scopes)||JSON.stringify(resources)!==JSON.stringify(input.request.resources)||JSON.stringify(audiences)!==JSON.stringify(input.request.audiences)))continue;return grantId}return null}export async function hasReusableOAuthConsent(input){return await reusableOAuthConsentGrantId(input)!==null}export async function reuseOAuthAuthorizationRequestSession(input){if(input.provider.consent.rememberFor<=0)return null;try{return await withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new NoReusableOAuthConsentError;const grantId=await reusableOAuthConsentGrantId({request,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId,rememberForMs:input.provider.consent.rememberFor},{exact:!0,lock:!0});if(!grantId)throw new NoReusableOAuthConsentError;return{code:await issueAuthorizationCode({grantId,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.provider.lifetimes.authorizationCode}),grantId,redirectUri:request.redirectUri,state:request.state}})}catch(error){if(error instanceof NoReusableOAuthConsentError)return null;throw error}}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new OAuthAuthorizationRequestError("invalid_request","OAuth consent workspace changed after it was displayed.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
@@ -2,6 +2,8 @@
2
2
  export declare function createOAuthGrant(input: CreateOAuthGrantInput): Promise<OAuthGrant>;
3
3
  /** Revoke one consent grant. Replays are idempotent and report no new change. */
4
4
  export declare function revokeOAuthGrant(id: string): Promise<boolean>;
5
+ /** Revoke one subject's delegated grants and pending codes during recovery. */
6
+ export declare function revokeOAuthSubjectAuthorizationState(subjectType: string, subjectId: number): Promise<void>;
5
7
  /** List the active third-party applications connected to one OAuth subject. */
6
8
  export declare function listOAuthConnections(subjectType: string, subjectId: number): Promise<OAuthConnectedApplication[]>;
7
9
  /** Disconnect one application without allowing another subject to revoke it. */
@@ -1,8 +1,11 @@
1
- import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function explicitValues(name,values){if(values.some((value)=>!value)||new Set(values).size!==values.length)throw TypeError(`OAuth grant ${name} must contain unique non-empty values.`);return[...values]}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}export async function createOAuthGrant(input){if(!validIdentifier(input.clientId)||!validIdentifier(input.subjectId))throw TypeError("OAuth grant client and subject identifiers must be positive safe integers.");if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType))throw TypeError("OAuth grant subject type is invalid.");if(input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))throw TypeError("OAuth grant workspace identifier must be 1 to 255 characters.");const scopes=explicitValues("scopes",input.scopes),resources=explicitValues("resources",input.resources),audiences=explicitValues("audiences",input.audiences),id=randomBytes(16).toString("hex"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect()),bound=sql.params(id,input.clientId,input.subjectType,input.subjectId,JSON.stringify(scopes),JSON.stringify(resources),JSON.stringify(audiences),input.workspaceId??null,sqlDateTime(createdAt));await db.transaction(async(rawTrx)=>{const trx=rawTrx,clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
2
- SELECT id FROM oauth_clients
3
- WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
1
+ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{oauthAuthorizationClientFromStored}from"./oauth-client-policy";function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function explicitValues(name,values){if(values.some((value)=>!value)||new Set(values).size!==values.length)throw TypeError(`OAuth grant ${name} must contain unique non-empty values.`);return[...values]}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}export async function createOAuthGrant(input){if(!validIdentifier(input.clientId)||!validIdentifier(input.subjectId))throw TypeError("OAuth grant client and subject identifiers must be positive safe integers.");if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType))throw TypeError("OAuth grant subject type is invalid.");if(input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))throw TypeError("OAuth grant workspace identifier must be 1 to 255 characters.");const scopes=explicitValues("scopes",input.scopes),resources=explicitValues("resources",input.resources),audiences=explicitValues("audiences",input.audiences);if(scopes.length===0)throw TypeError("OAuth grant must contain at least one scope.");if(resources.length!==audiences.length)throw TypeError("OAuth grant resource and audience bindings must have equal lengths.");const id=randomBytes(16).toString("hex"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect()),bound=sql.params(id,input.clientId,input.subjectType,input.subjectId,JSON.stringify(scopes),JSON.stringify(resources),JSON.stringify(audiences),input.workspaceId??null,sqlDateTime(createdAt));await db.transaction(async(rawTrx)=>{const trx=rawTrx,clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",clients=await trx.unsafe(`
2
+ SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
3
+ token_endpoint_auth_method, allowed_scopes, allowed_resources,
4
+ personal_access_client, password_client, revoked
5
+ FROM oauth_clients
6
+ WHERE id = ${sql.param(1)}
4
7
  LIMIT 1${clientLock}
5
- `,[input.clientId])).length!==1)throw Error("OAuth client is not active.");await trx.unsafe(`
8
+ `,[input.clientId]),client=oauthAuthorizationClientFromStored(clients[0],input.clientId);if(!client||client.revoked)throw Error("OAuth client is not an active provider authorization client.");if(scopes.some((scope)=>!client.scopes.includes(scope)))throw Error("OAuth grant scope is not registered to the client.");if(resources.some((resource)=>!client.resources.includes(resource)))throw Error("OAuth grant resource is not registered to the client.");await trx.unsafe(`
6
9
  INSERT INTO oauth_grants (
7
10
  id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id, created_at
8
11
  ) VALUES (${bound.sql})
@@ -10,7 +13,26 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
10
13
  UPDATE oauth_grants
11
14
  SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
12
15
  WHERE id = ${sql.param(3)} AND revoked_at IS NULL
13
- `,[now,now,id]).execute(),revoked=mutationCount(result)===1;if(revoked)markContextWrote();return revoked}export async function listOAuthConnections(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
16
+ `,[now,now,id]).execute(),revoked=mutationCount(result)===1;if(revoked)markContextWrote();return revoked}export async function revokeOAuthSubjectAuthorizationState(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))throw TypeError("OAuth authorization subject is invalid.");const sql=sqlHelpers(getDatabaseDialect()),now=sqlDateTime(new Date);try{await db.transaction(async(rawTrx)=>{const trx=rawTrx;await trx.unsafe(`
17
+ UPDATE oauth_grants
18
+ SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
19
+ WHERE subject_type = ${sql.param(3)} AND subject_id = ${sql.param(4)}
20
+ AND revoked_at IS NULL
21
+ `,[now,now,subjectType,subjectId]);await trx.unsafe(`
22
+ UPDATE oauth_auth_codes
23
+ SET consumed_at = ${sql.param(1)}
24
+ WHERE subject_type = ${sql.param(2)} AND subject_id = ${sql.param(3)}
25
+ AND consumed_at IS NULL
26
+ `,[now,subjectType,subjectId]);if((await trx.unsafe(`
27
+ SELECT id FROM oauth_grants
28
+ WHERE subject_type = ${sql.param(1)} AND subject_id = ${sql.param(2)}
29
+ AND revoked_at IS NULL
30
+ UNION ALL
31
+ SELECT code_hash FROM oauth_auth_codes
32
+ WHERE subject_type = ${sql.param(3)} AND subject_id = ${sql.param(4)}
33
+ AND consumed_at IS NULL
34
+ LIMIT 1
35
+ `,[subjectType,subjectId,subjectType,subjectId])).length>0)throw Error("[auth] OAuth authorization state could not be revoked.")})}catch(error){const message=error&&typeof error==="object"&&"message"in error&&typeof error.message==="string"?error.message:String(error);if(/^(?:no such table: (?:main\.)?oauth_(?:grants|auth_codes)|relation "oauth_(?:grants|auth_codes)" does not exist|Table '(?:[^'.]+\.)?oauth_(?:grants|auth_codes)' doesn't exist)$/i.test(message))return;throw error}markContextWrote()}export async function listOAuthConnections(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
14
36
  SELECT g.id AS grant_id, g.client_id, c.name AS client_name, g.scopes,
15
37
  g.resources, g.audiences, g.workspace_id, g.created_at
16
38
  FROM oauth_grants g
@@ -1,4 +1,6 @@
1
1
  import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
+ /** Derive the RFC 8414 well-known path, including an issuer path when present. */
3
+ export declare function oauthAuthorizationServerMetadataPath(issuer: string): string;
2
4
  /** Build RFC 8414 metadata from the provider profile Stacks actually serves. */
3
5
  export declare function oauthAuthorizationServerMetadata(provider: ResolvedOAuthProviderConfig): OAuthAuthorizationServerMetadata;
4
6
  export declare interface OAuthAuthorizationServerMetadata {
@@ -1 +1 @@
1
- function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function oauthAuthorizationServerMetadata(provider){const clientMethods=clientAuthenticationMethods(provider);return{issuer:provider.issuer,authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()}}
1
+ function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function oauthAuthorizationServerMetadataPath(issuer){const issuerPath=new URL(issuer).pathname.replace(/^\/+|\/+$/g,"");return`/.well-known/oauth-authorization-server${issuerPath?`/${issuerPath}`:""}`}export function oauthAuthorizationServerMetadata(provider){const clientMethods=clientAuthenticationMethods(provider);return{issuer:provider.issuer,authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()}}
@@ -1,4 +1,6 @@
1
- import type { OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
1
+ import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
2
+ /** Resolve and validate application-owned workspace authority for consent. */
3
+ export declare function resolveOAuthConsentWorkspace(provider: ResolvedOAuthProviderConfig, context: OAuthProviderConsentWorkspaceContext): Promise<OAuthProviderConsentWorkspace | null>;
2
4
  /**
3
5
  * Resolve the disabled-by-default OAuth authorization-server contract.
4
6
  *
@@ -16,7 +18,6 @@ export declare interface ResolvedOAuthProviderConfig {
16
18
  authorization: string
17
19
  token: string
18
20
  revocation: string
19
- introspection: string
20
21
  }
21
22
  responseTypes: readonly ['code']
22
23
  grantTypes: readonly ['authorization_code', 'refresh_token']
@@ -33,5 +34,6 @@ export declare interface ResolvedOAuthProviderConfig {
33
34
  consent: {
34
35
  rememberFor: number
35
36
  view: string
37
+ resolveWorkspace?: OAuthProviderConsentWorkspaceResolver
36
38
  }
37
39
  }
@@ -1 +1 @@
1
- const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"};function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function positiveLifetime(name,value){if(!Number.isFinite(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive number of milliseconds.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isFinite(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative number of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");return{rememberFor,view}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)};return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints:{authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)},responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
1
+ import{oauthAuthorizationServerMetadataPath}from"./oauth-metadata";const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke"},RESERVED_AUTH_GET_PATHS=new Set(["/generate-registration-options","/generate-authentication-options"]),RESERVED_AUTH_POST_PATHS=new Set(["/login","/register","/verify-registration","/verify-authentication","/generate-two-factor-secret","/enable-two-factor","/disable-two-factor","/verify-two-factor-login"]);function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function assertEndpointRouteAvailable(name,value,methods){const pathname=new URL(value).pathname,reservedNamespace=pathname==="/auth"||pathname.startsWith("/auth/"),reservedMethod=methods.some((method)=>method==="GET"?RESERVED_AUTH_GET_PATHS.has(pathname):RESERVED_AUTH_POST_PATHS.has(pathname));if(reservedNamespace||reservedMethod)throw Error(`auth.oauthProvider ${name} endpoint must not shadow a reserved auth route: ${pathname}`)}function positiveLifetime(name,value){if(!Number.isSafeInteger(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive safe integer of milliseconds.`);if(!Number.isFinite(new Date(Date.now()+value).getTime()))throw Error(`auth.oauthProvider.lifetimes.${name} is outside the supported date range.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isSafeInteger(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative safe integer of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");const resolveWorkspace=options?.resolveWorkspace;if(resolveWorkspace!==void 0&&typeof resolveWorkspace!=="function")throw Error("auth.oauthProvider.consent.resolveWorkspace must be a function.");return{rememberFor,view,...resolveWorkspace?{resolveWorkspace}:{}}}export async function resolveOAuthConsentWorkspace(provider,context){const resolved=await provider.consent.resolveWorkspace?.(context)??null;if(resolved===null)return null;if(typeof resolved.id!=="string"||!resolved.id||resolved.id.length>255||/[\u0000-\u001F\u007F]/.test(resolved.id))throw Error("OAuth consent workspace id must contain 1 to 255 characters without controls.");if(typeof resolved.label!=="string"||!resolved.label.trim()||resolved.label.length>255||/[\u0000-\u001F\u007F]/.test(resolved.label))throw Error("OAuth consent workspace label must contain 1 to 255 characters without controls.");return{id:resolved.id,label:resolved.label.trim()}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)},endpoints={authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation)};if(new Set(Object.values(endpoints)).size!==Object.keys(endpoints).length)throw Error("auth.oauthProvider endpoints must each use a unique URL.");assertEndpointRouteAvailable("authorization",endpoints.authorization,["GET","POST"]);assertEndpointRouteAvailable("token",endpoints.token,["POST"]);assertEndpointRouteAvailable("revocation",endpoints.revocation,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
@@ -0,0 +1,10 @@
1
+ /** Delete expired OAuth browser requests and codes without touching active rows. */
2
+ export declare function pruneOAuthAuthorizationArtifacts(options?: OAuthAuthorizationPruneOptions): Promise<OAuthAuthorizationPruneResult>;
3
+ export declare interface OAuthAuthorizationPruneOptions {
4
+ now?: Date
5
+ consumedRetentionMs?: number
6
+ }
7
+ export declare interface OAuthAuthorizationPruneResult {
8
+ authorizationRequests: number
9
+ authorizationCodes: number
10
+ }
@@ -0,0 +1,9 @@
1
+ import{db,getDatabaseDialect,markContextWrote,mutationCount,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";export async function pruneOAuthAuthorizationArtifacts(options={}){const now=options.now??new Date,consumedRetentionMs=options.consumedRetentionMs??604800000;if(!Number.isFinite(now.getTime()))throw TypeError("OAuth authorization prune time must be a valid date.");if(!Number.isFinite(consumedRetentionMs)||consumedRetentionMs<0)throw TypeError("OAuth authorization consumed retention must be a non-negative number of milliseconds.");const sql=sqlHelpers(getDatabaseDialect()),nowSql=sqlDateTime(now),consumedBefore=sqlDateTime(new Date(now.getTime()-consumedRetentionMs)),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,requests=await trx.unsafe(`
2
+ DELETE FROM oauth_authorization_requests
3
+ WHERE expires_at <= ${sql.param(1)}
4
+ OR (consumed_at IS NOT NULL AND consumed_at <= ${sql.param(2)})
5
+ `,[nowSql,consumedBefore]),codes=await trx.unsafe(`
6
+ DELETE FROM oauth_auth_codes
7
+ WHERE expires_at <= ${sql.param(1)}
8
+ OR (consumed_at IS NOT NULL AND consumed_at <= ${sql.param(2)})
9
+ `,[nowSql,consumedBefore]);return{authorizationRequests:mutationCount(requests),authorizationCodes:mutationCount(codes)}});if(result.authorizationRequests>0||result.authorizationCodes>0)markContextWrote();return result}
@@ -1,8 +1,12 @@
1
+ import type { OAuthSubjectEligibility } from './oauth-token-exchange';
1
2
  import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
3
  /** Execute one token request against the resolved provider without registering a route. */
3
- export declare function handleOAuthTokenRequest(provider: ResolvedOAuthProviderConfig, input: OAuthTokenEndpointRequest): Promise<Response>;
4
+ export declare function handleOAuthTokenRequest(provider: ResolvedOAuthProviderConfig, input: OAuthTokenEndpointRequest, dependencies: OAuthTokenEndpointDependencies): Promise<Response>;
4
5
  export declare interface OAuthTokenEndpointRequest {
5
6
  body: string
6
7
  contentType?: string | null
7
8
  authorization?: string | null
8
9
  }
10
+ export declare interface OAuthTokenEndpointDependencies {
11
+ isSubjectEligible: OAuthSubjectEligibility
12
+ }
@@ -1 +1 @@
1
- import{exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
1
+ import{exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input,dependencies){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
@@ -1,4 +1,5 @@
1
1
  import type { AuthorizationCodeGrant, AuthorizationCodeRedemption, AuthorizationCodeResult } from './oauth-authorization-codes';
2
+ import type { OAuthDelegatedSubject } from './oauth-delegated-access';
2
3
  /** Atomically consume an authorization code and mint its grant-bound token pair. */
3
4
  export declare function exchangeAuthorizationCode(input: ExchangeAuthorizationCodeInput): Promise<AuthorizationCodeResult<DelegatedTokenPair>>;
4
5
  /** Authenticate the provider client and exchange its code under one policy lock. */
@@ -22,6 +23,7 @@ export declare interface ExchangeAuthorizationCodeInput extends AuthorizationCod
22
23
  accessTokenLifetimeMs: number
23
24
  refreshTokenLifetimeMs: number
24
25
  issueRefreshToken?: boolean
26
+ isSubjectEligible?: OAuthSubjectEligibility
25
27
  }
26
28
  export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAuthorizationCodeInput {
27
29
  clientSecret?: string
@@ -33,6 +35,7 @@ export declare interface RefreshOAuthDelegatedTokenInput {
33
35
  scopes?: readonly string[]
34
36
  accessTokenLifetimeMs: number
35
37
  refreshTokenLifetimeMs: number
38
+ isSubjectEligible?: OAuthSubjectEligibility
36
39
  }
37
40
  export declare interface DelegatedTokenPair {
38
41
  accessToken: string
@@ -48,6 +51,7 @@ export declare interface DelegatedTokenPair {
48
51
  audiences: string[]
49
52
  workspaceId: string | null
50
53
  }
54
+ export type OAuthSubjectEligibility = (subject: OAuthDelegatedSubject) => boolean | Promise<boolean>;
51
55
  declare type DelegatedGrant = Omit<AuthorizationCodeGrant, 'redirectUri'>;
52
56
  export type OAuthAuthorizationCodeExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
53
57
  | typeof invalidClient;
@@ -1,4 +1,10 @@
1
- import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"};function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validLifetime(lifetimeMs){if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth token lifetime must be a positive safe integer.");return lifetimeMs}function deadline(issuedAt,lifetimeMs,mysql){const value=new Date(issuedAt.getTime()+lifetimeMs);if(!Number.isFinite(value.getTime()))throw TypeError("OAuth token lifetime is outside the supported date range.");if(mysql)value.setUTCMilliseconds(0);return value}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function activeFlag(value){return value===!1||value===0}async function mintDelegatedTokenPair(grant,options){const sql=sqlHelpers(getDatabaseDialect()),accessToken=randomBytes(40).toString("hex"),refreshToken=options.issueRefreshToken?randomBytes(40).toString("hex"):void 0,refreshFamilyId=refreshToken?options.refreshFamilyId??randomBytes(16).toString("hex"):void 0;if(refreshFamilyId&&!/^[a-f0-9]{32}$/.test(refreshFamilyId))throw TypeError("OAuth refresh token family identifier is invalid.");const accessHash=tokenHash(accessToken),refreshHash=refreshToken?tokenHash(refreshToken):void 0,createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const accessExpiresAt=deadline(createdAt,options.accessLifetimeMs,sql.isMysql),refreshExpiresAt=options.issueRefreshToken?deadline(createdAt,options.refreshLifetimeMs,sql.isMysql):void 0,revoked=sql.isPostgres?!1:0,accessValues=sql.params(grant.subjectType,grant.subjectId,grant.subjectId,grant.clientId,accessHash,`oauth:${grant.grantId}`,JSON.stringify(grant.scopes),grant.grantId,JSON.stringify(grant.resources),JSON.stringify(grant.audiences),grant.workspaceId,revoked,sqlDateTime(accessExpiresAt),sqlDateTime(createdAt),sqlDateTime(createdAt));await db.unsafe(`
1
+ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{disconnectOAuthGrant}from"./oauth-grants";import{verifyS256CodeChallenge}from"./oauth-pkce";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"},inactiveSubject=Symbol("inactive-oauth-subject");function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}async function containAuthorizationCodeReplay(input){if(!/^[A-Za-z0-9_-]{43}$/.test(input.code))return;const sql=sqlHelpers(getDatabaseDialect()),code=(await db.unsafe(`
2
+ SELECT grant_id, client_id, subject_type, subject_id, redirect_uri,
3
+ code_challenge, code_challenge_method, consumed_at
4
+ FROM oauth_auth_codes
5
+ WHERE code_hash = ${sql.param(1)}
6
+ LIMIT 1
7
+ `,[tokenHash(input.code)]))[0],subjectId=Number(code?.subject_id);if(!code?.consumed_at||!code.grant_id||!/^[a-f0-9]{32}$/.test(code.grant_id)||String(code.client_id)!==String(input.clientId)||!Number.isSafeInteger(subjectId)||subjectId<=0||code.redirect_uri!==input.redirectUri||code.code_challenge_method!=="S256"||!await verifyS256CodeChallenge(input.codeVerifier,code.code_challenge))return;await disconnectOAuthGrant(code.subject_type,subjectId,code.grant_id)}function validLifetime(lifetimeMs){if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth token lifetime must be a positive safe integer.");return lifetimeMs}function deadline(issuedAt,lifetimeMs,mysql){const value=new Date(issuedAt.getTime()+lifetimeMs);if(!Number.isFinite(value.getTime()))throw TypeError("OAuth token lifetime is outside the supported date range.");if(mysql)value.setUTCMilliseconds(0);return value}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function activeFlag(value){return value===!1||value===0}async function mintDelegatedTokenPair(grant,options){const sql=sqlHelpers(getDatabaseDialect()),accessToken=randomBytes(40).toString("hex"),refreshToken=options.issueRefreshToken?randomBytes(40).toString("hex"):void 0,refreshFamilyId=refreshToken?options.refreshFamilyId??randomBytes(16).toString("hex"):void 0;if(refreshFamilyId&&!/^[a-f0-9]{32}$/.test(refreshFamilyId))throw TypeError("OAuth refresh token family identifier is invalid.");const accessHash=tokenHash(accessToken),refreshHash=refreshToken?tokenHash(refreshToken):void 0,createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const accessExpiresAt=deadline(createdAt,options.accessLifetimeMs,sql.isMysql),refreshExpiresAt=options.issueRefreshToken?deadline(createdAt,options.refreshLifetimeMs,sql.isMysql):void 0,revoked=sql.isPostgres?!1:0,accessValues=sql.params(grant.subjectType,grant.subjectId,grant.subjectId,grant.clientId,accessHash,`oauth:${grant.grantId}`,JSON.stringify(grant.scopes),grant.grantId,JSON.stringify(grant.resources),JSON.stringify(grant.audiences),grant.workspaceId,revoked,sqlDateTime(accessExpiresAt),sqlDateTime(createdAt),sqlDateTime(createdAt));await db.unsafe(`
2
8
  INSERT INTO oauth_access_tokens (
3
9
  tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes,
4
10
  oauth_grant_id, resources, audiences, workspace_id, revoked, expires_at, created_at, updated_at
@@ -23,11 +29,11 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
23
29
  UPDATE oauth_refresh_tokens
24
30
  SET revoked = ${sql.param(1)}
25
31
  WHERE family_id = ${sql.param(2)}
26
- `,[revoked,refresh.family_id]);return}await db.unsafe(`UPDATE oauth_access_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.access_token_id]);await db.unsafe(`UPDATE oauth_refresh_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.id])}export async function exchangeAuthorizationCode(input){const sql=sqlHelpers(getDatabaseDialect()),accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),issueRefreshToken=input.issueRefreshToken!==!1,refreshLifetimeMs=issueRefreshToken?validLifetime(input.refreshTokenLifetimeMs):0;try{return await withAuthorizationCode(input.code,input,async(grant)=>{const clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await db.unsafe(`
32
+ `,[revoked,refresh.family_id]);return}await db.unsafe(`UPDATE oauth_access_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.access_token_id]);await db.unsafe(`UPDATE oauth_refresh_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.id])}export async function exchangeAuthorizationCode(input){const sql=sqlHelpers(getDatabaseDialect()),accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),issueRefreshToken=input.issueRefreshToken!==!1,refreshLifetimeMs=issueRefreshToken?validLifetime(input.refreshTokenLifetimeMs):0;try{const result=await withAuthorizationCode(input.code,input,async(grant)=>{const clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await db.unsafe(`
27
33
  SELECT id FROM oauth_clients
28
34
  WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
29
35
  LIMIT 1${clientLock}
30
- `,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})})}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")}))??invalidClient}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
36
+ `,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return inactiveSubject}return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})});if(!result.ok)return result;return result.value===inactiveSubject?invalidGrant:{ok:!0,value:result.value}}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>{const exchanged=await exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")});if(!exchanged.ok)await containAuthorizationCodeReplay(exchange);return exchanged})??invalidClient}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
31
37
  SELECT r.id, r.family_id, a.oauth_client_id
32
38
  FROM oauth_refresh_tokens r
33
39
  JOIN oauth_access_tokens a ON a.id = r.access_token_id
@@ -46,7 +52,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
46
52
  JOIN oauth_access_tokens a ON a.id = r.access_token_id
47
53
  WHERE r.id = ${sql.param(1)} AND r.token = ${sql.param(2)}
48
54
  LIMIT 1${lock}
49
- `,[candidate.id,hash]))[0];if(!refresh||String(refresh.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};const familyIsValid=Boolean(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id));if(!activeFlag(refresh.revoked)||!activeFlag(refresh.access_revoked)||!familyIsValid){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if((parseSqlDateTime(refresh.expires_at)?.getTime()??0)<=Date.now())return{result:invalidGrant,wrote:!1};const grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grants=await db.unsafe(`
55
+ `,[candidate.id,hash]))[0];if(!refresh||String(refresh.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};const familyIsValid=Boolean(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id));if(!activeFlag(refresh.revoked)||!activeFlag(refresh.access_revoked)||!familyIsValid){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if((parseSqlDateTime(refresh.expires_at)?.getTime()??0)<=Date.now())return{result:invalidGrant,wrote:!1};const grantLock=sql.isSqlite?"":" FOR UPDATE",grants=await db.unsafe(`
50
56
  SELECT id, client_id AS grant_client_id, subject_type AS grant_subject_type,
51
57
  subject_id AS grant_subject_id, scopes AS grant_scopes,
52
58
  resources AS grant_resources, audiences AS grant_audiences,
@@ -54,7 +60,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
54
60
  FROM oauth_grants
55
61
  WHERE id = ${sql.param(1)}
56
62
  LIMIT 1${grantLock}
57
- `,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
63
+ `,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return{result:invalidGrant,wrote:!0}}const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
58
64
  UPDATE oauth_refresh_tokens
59
65
  SET revoked = ${sql.param(1)}
60
66
  WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
@@ -1,3 +1,3 @@
1
- import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).where("email","=",email).select("id");if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();if(!await ownerQuery.executeTakeFirst())return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await db.primary.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return;const token=await createResetToken(user.id);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
1
+ import{randomBytes}from"node:crypto";import{config,feature,overridesReady}from"@stacksjs/config";import{db,enqueueAfterCommit,getDatabaseDialect,mutationCount,parseSqlDateTime,sqlDateTime}from"@stacksjs/database/runtime";import{mail,template}from"@stacksjs/email";import{log}from"@stacksjs/logging";import{mountedDefaultRouteBundles}from"@stacksjs/router";import{makeHash,verifyHash}from"@stacksjs/security";import{authLinkBase}from"../link-url";import{sessionDestroyAll}from"../session-auth";import{revokeAllTokens}from"../tokens";import{revokeTwoFactorChallenges}from"../two-factor";import{revokeMagicLinks}from"../magic-link-revocation";import{revokeOAuthSubjectAuthorizationState}from"../oauth-grants";function getTokenExpireMinutes(){return config.auth.passwordReset?.expire??60}function isWithinExpiry(row){const explicit=row.expires_at;if(explicit!=null)return(parseSqlDateTime(explicit)?.getTime()??0)>Date.now();const created=parseSqlDateTime(row.created_at),expireMinutes=getTokenExpireMinutes();return created!=null&&Number.isFinite(expireMinutes)&&expireMinutes>0&&created.getTime()+expireMinutes*60000>Date.now()}async function sendPasswordChangedNotification(userEmail){const appName=config.app.name||"Stacks",supportEmail=config.app.supportEmail||config.email?.from?.address||"",changedAt=new Date().toLocaleString("en-US",{dateStyle:"full",timeStyle:"short"});try{const{html,text}=await template("password-changed",{subject:`Your ${appName} password has been changed`,variables:{changedAt,supportEmail}});if(!html&&!text){await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text:`Your ${appName} password was changed on ${changedAt}.${supportEmail?` If this wasn't you, contact ${supportEmail}.`:""}`});return}await mail.sendOrFail({to:userEmail,subject:`Your ${appName} password has been changed`,text,html})}catch(error){console.error("[PasswordReset] Failed to send password changed notification:",error)}}export function passwordResets(email){function generateResetToken(){return randomBytes(32).toString("hex")}async function createResetToken(userId){const token=generateResetToken(),hashedToken=await makeHash(token,{algorithm:"bcrypt"}),expireMinutes=getTokenExpireMinutes();return await db.transaction(async()=>{let ownerQuery=db.primary.selectFrom("users").where("id","=",userId).where("email","=",email).select("id");if(getDatabaseDialect()!=="sqlite")ownerQuery=ownerQuery.lockForUpdate();if(!await ownerQuery.executeTakeFirst())return!1;const createdAt=new Date,expiresAt=sqlDateTime(new Date(createdAt.getTime()+expireMinutes*60000));await db.deleteFrom("password_resets").where("email","=",email).execute();await db.insertInto("password_resets").values({email,token:hashedToken,expires_at:expiresAt,created_at:sqlDateTime(createdAt)}).executeTakeFirst();const stored=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().execute();if(stored.length!==1||stored[0]?.token!==hashedToken)throw Error("Password reset could not replace its token");return!0},getDatabaseDialect()==="sqlite"?{retries:3}:void 0)?token:void 0}async function sendEmail(){const user=await db.primary.selectFrom("users").where("email","=",email).selectAll().executeTakeFirst();if(!user)return;const token=await createResetToken(user.id);if(!token)return;const deliver=()=>deliverResetEmail(token);if(!enqueueAfterCommit(deliver))await deliver()}async function deliverResetEmail(token){const expireMinutes=getTokenExpireMinutes(),appName=config.app.name||"Stacks",base=authLinkBase(),tpl=config.auth.passwordReset?.url??"/password/reset/{token}?email={email}";if(!config.auth.passwordReset?.url)await warnIfDefaultResetPageMissing();const filled=tpl.replace("{token}",token).replace("{email}",encodeURIComponent(email)),resetUrl=/^https?:\/\//.test(filled)?filled:`${base}${filled.startsWith("/")?"":"/"}${filled}`;let html,text;try{const rendered=await template("password-reset",{subject:`Reset Your ${appName} Password`,variables:{resetUrl,expireMinutes}});html=rendered.html;text=rendered.text;if(!html&&!text)throw Error("password-reset template missing or rendered empty")}catch(templateError){const msg=templateError instanceof Error?templateError.message:String(templateError);console.warn(`[PasswordReset] template render failed, sending plain-text fallback: ${msg}`);html=void 0;text=`Reset your password by visiting: ${resetUrl}
2
2
 
3
- This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
3
+ This link expires in ${expireMinutes} minutes. If you didn't request this, you can safely ignore this email.`}await mail.sendOrFail({to:email,subject:`Reset Your ${appName} Password`,text,html})}async function verifyToken(token){const result=await db.primary.selectFrom("password_resets").where("email","=",email).selectAll().executeTakeFirst();if(!result)return!1;if(!isWithinExpiry(result)){await db.deleteFrom("password_resets").where("email","=",email).where("token","=",result.token).execute();return!1}const hashedToken=result.token;return await verifyHash(token,hashedToken)}async function resetPassword(token,newPassword){const result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let userQuery=trx.selectFrom("users").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")userQuery=userQuery.lockForUpdate();const user=await userQuery.executeTakeFirst();if(!user)return{success:!1,message:"Invalid or expired reset token"};let resetQuery=trx.selectFrom("password_resets").where("email","=",email).selectAll();if(getDatabaseDialect()!=="sqlite")resetQuery=resetQuery.lockForUpdate();const resetRecord=await resetQuery.executeTakeFirst();if(!resetRecord)return{success:!1,message:"Invalid or expired reset token"};if(!isWithinExpiry(resetRecord)){await trx.deleteFrom("password_resets").where("email","=",email).execute();return{success:!1,message:"This password reset link has expired. Please request a new one."}}const hashedToken=resetRecord.token;if(!await verifyHash(token,hashedToken))return{success:!1,message:"Invalid or expired reset token"};const hashedPassword=await makeHash(newPassword,{algorithm:"bcrypt"});if(!isWithinExpiry(resetRecord))return{success:!1,message:"Invalid or expired reset token"};const stamped=Object.hasOwn(user,"password_changed_at");if(!stamped)log.warn("[PasswordReset] password_changed_at column missing - run `buddy migrate`; resetting without the credential-version stamp");const updated=await trx.updateTable("users").set(stamped?{password:hashedPassword,password_changed_at:sqlDateTime(new Date)}:{password:hashedPassword}).where("id","=",user.id).execute();if(mutationCount(updated)!==1)throw Error("Password reset could not update its owner");const consumed=await trx.deleteFrom("password_resets").where("email","=",email).where("token","=",hashedToken).execute();if(mutationCount(consumed)!==1)throw Error("Password reset token could not be consumed");await revokeTwoFactorChallenges(Number(user.id));await revokeMagicLinks(Number(user.id));await revokeOAuthSubjectAuthorizationState("users",Number(user.id));await revokeAllTokens(Number(user.id));await sessionDestroyAll(Number(user.id));return{success:!0}});if(result.success){const notify=()=>sendPasswordChangedNotification(email).catch((err)=>{console.error("[PasswordReset] Failed to send notification:",err)});if(!enqueueAfterCommit(notify))notify();return{success:!0}}return result}return{sendEmail,verifyToken,resetPassword}}let warnedDefaultResetPage=!1;async function warnIfDefaultResetPageMissing(){if(warnedDefaultResetPage)return;warnedDefaultResetPage=!0;try{await overridesReady;if(mountedDefaultRouteBundles(feature).has("auth"))return}catch{return}log.warn("[PasswordReset] Reset links point at the default /password/reset/{token} page, which needs the auth route bundle, and it is not mounted (it mounts with the dashboard, or with STACKS_DEFAULT_ROUTES=auth). Set config.auth.passwordReset.url to your own reset page, or mount the bundle.")}
@@ -9,13 +9,18 @@ export declare interface RateLimitEntry {
9
9
  * default {@link MemoryStore} is process-local (fine for single-instance and
10
10
  * dev). On a horizontally-scaled deployment the in-memory store is trivially
11
11
  * bypassed by spreading attempts across instances, so production should swap
12
- * in a shared store via `RateLimiter.useSharedStore()` (cache-backed; becomes
13
- * cluster-wide when the cache driver is Redis) or a custom `useStore()`.
12
+ * in the atomic Redis store via `RateLimiter.useSharedStore()` or provide a
13
+ * custom store whose `recordFailedAttempt` operation is atomic.
14
14
  */
15
15
  export declare interface RateLimiterStore {
16
16
  get: (key: string) => Promise<RateLimitEntry | undefined> | RateLimitEntry | undefined
17
- set: (key: string, entry: RateLimitEntry, ttlMs: number) => Promise<void> | void
18
17
  delete: (key: string) => Promise<void> | void
18
+ recordFailedAttempt: (key: string, now: number, ttlMs: number) => Promise<void> | void
19
+ close?: () => Promise<void> | void
20
+ }
21
+ export declare interface RedisRateLimiterStoreOptions {
22
+ url?: string
23
+ prefix?: string
19
24
  }
20
25
  /** Process-local store โ€” the default. */
21
26
  declare class MemoryStore implements RateLimiterStore {
@@ -24,20 +29,20 @@ declare class MemoryStore implements RateLimiterStore {
24
29
  delete(key: string): void;
25
30
  recordFailedAttempt(key: string, now: number): void;
26
31
  }
27
- /**
28
- * Cache-backed store. Cross-instance when the configured cache driver is
29
- * Redis; otherwise behaves like an in-memory store with TTL eviction. Entries
30
- * carry a TTL so attempts decay automatically โ€” no separate eviction pass.
31
- */
32
- declare class CacheStore implements RateLimiterStore {
32
+ /** Redis-backed store whose Lua update is atomic across workers. */
33
+ export declare class RedisRateLimiterStore implements RateLimiterStore {
34
+ constructor(options?: RedisRateLimiterStoreOptions);
33
35
  get(key: string): Promise<RateLimitEntry | undefined>;
34
36
  set(key: string, entry: RateLimitEntry, ttlMs: number): Promise<void>;
35
37
  delete(key: string): Promise<void>;
38
+ recordFailedAttempt(key: string, now: number, ttlMs: number): Promise<void>;
39
+ close(): Promise<void>;
36
40
  }
37
41
  export declare class RateLimiter {
38
42
  static useStore(custom: RateLimiterStore): void;
39
- static useSharedStore(): void;
43
+ static useSharedStore(options?: RedisRateLimiterStoreOptions): void;
40
44
  static useMemoryStore(): void;
45
+ static useConfiguredStore(): void;
41
46
  static isRateLimited(email: string): Promise<boolean>;
42
47
  static recordFailedAttempt(email: string): Promise<void>;
43
48
  static resetAttempts(email: string): Promise<void>;
@@ -1 +1,26 @@
1
- import{HttpError}from"@stacksjs/error-handling";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=MAX_ATTEMPTS){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}class CacheStore{prefix="auth:ratelimit:";async get(key){const{cache}=await import("@stacksjs/cache"),raw=await cache.get(`${this.prefix}${key}`);if(raw==null)return;try{return typeof raw==="string"?JSON.parse(raw):raw}catch{return}}async set(key,entry,ttlMs){const{cache}=await import("@stacksjs/cache");await cache.set(`${this.prefix}${key}`,JSON.stringify(entry),Math.ceil(ttlMs/1000))}async delete(key){const{cache}=await import("@stacksjs/cache");await cache.remove(`${this.prefix}${key}`)}}let store=new MemoryStore;export class RateLimiter{static useStore(custom){store=custom}static useSharedStore(){store=new CacheStore}static useMemoryStore(){store=new MemoryStore}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await store.get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();if(store instanceof MemoryStore){store.recordFailedAttempt(email,now);return}const entry=recordFailure(await store.get(email),now);await store.set(email,entry,LOCKOUT_DURATION)}static async resetAttempts(email){await store.delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
1
+ import{HttpError}from"@stacksjs/error-handling";import{RedisClient}from"bun";const MAX_ATTEMPTS=5,LOCKOUT_DURATION=900000;function recordFailure(current,now){const expired=current&&current.lockedUntil>0&&current.lockedUntil<=now,entry=current&&!expired?current:{attempts:0,lockedUntil:0};entry.attempts++;if(entry.attempts>=MAX_ATTEMPTS){entry.lockedUntil=now+LOCKOUT_DURATION;entry.attempts=0}return entry}const INITIAL_EVICTION_SIZE=1e4,EVICTION_INTERVAL=300000;class MemoryStore{store=new Map;lastEviction=Date.now();nextEvictionSize=INITIAL_EVICTION_SIZE;evict(){const now=Date.now(),intervalElapsed=now-this.lastEviction>=EVICTION_INTERVAL,grew=this.store.size>=this.nextEvictionSize;if(!intervalElapsed&&!grew)return;this.lastEviction=now;for(const[key,value]of this.store)if(value.expiresAt<=now)this.store.delete(key);else if(value.entry.lockedUntil===0&&value.entry.attempts===0)this.store.delete(key);this.nextEvictionSize=Math.max(INITIAL_EVICTION_SIZE,this.store.size*2)}get(key){this.evict();const value=this.store.get(key);if(value&&value.expiresAt<=Date.now()){this.store.delete(key);return}return value?.entry}set(key,entry,ttlMs){this.store.set(key,{entry,expiresAt:Date.now()+ttlMs})}delete(key){this.store.delete(key)}recordFailedAttempt(key,now){this.set(key,recordFailure(this.get(key),now),LOCKOUT_DURATION)}}const RECORD_FAILURE_SCRIPT=`
2
+ local attempts = 0
3
+ local locked_until = 0
4
+ local raw = redis.call('GET', KEYS[1])
5
+ if raw then
6
+ local decoded_ok, decoded = pcall(cjson.decode, raw)
7
+ if decoded_ok and type(decoded) == 'table' then
8
+ attempts = tonumber(decoded.attempts) or 0
9
+ locked_until = tonumber(decoded.lockedUntil) or 0
10
+ end
11
+ end
12
+ local now = tonumber(ARGV[1])
13
+ local ttl = tonumber(ARGV[2])
14
+ local maximum = tonumber(ARGV[3])
15
+ if locked_until > 0 and locked_until <= now then
16
+ attempts = 0
17
+ locked_until = 0
18
+ end
19
+ attempts = attempts + 1
20
+ if attempts >= maximum then
21
+ locked_until = now + ttl
22
+ attempts = 0
23
+ end
24
+ redis.call('SET', KEYS[1], cjson.encode({ attempts = attempts, lockedUntil = locked_until }), 'PX', ttl)
25
+ return locked_until
26
+ `;function redisUrl(options,configured){const redis=configured.drivers?.redis;if(options.url)return{url:options.url,prefix:options.prefix??configured.prefix??"stacks"};if(configured.driver!=="redis"||!redis)throw Error("Shared auth rate limiting requires the Redis cache driver or an explicit Redis URL.");if(redis.url)return{url:redis.url,prefix:options.prefix??configured.prefix??"stacks"};const protocol=redis.tls?"rediss":"redis",credentials=redis.username?`${encodeURIComponent(redis.username)}:${encodeURIComponent(redis.password??"")}@`:redis.password?`:${encodeURIComponent(redis.password)}@`:"",database=redis.database==null?"":`/${redis.database}`;return{url:`${protocol}://${credentials}${redis.host??"127.0.0.1"}:${redis.port??6379}${database}`,prefix:options.prefix??configured.prefix??"stacks"}}export class RedisRateLimiterStore{options;clientPromise;constructor(options={}){this.options=options}async client(){if(!this.clientPromise)this.clientPromise=(async()=>{const{cache:configured}=await import("@stacksjs/config"),resolved=redisUrl(this.options,configured),client=new RedisClient(resolved.url);await client.connect();return{client,prefix:resolved.prefix}})().catch((error)=>{this.clientPromise=void 0;throw error});return this.clientPromise}async key(key){const{client,prefix}=await this.client();return{client,key:`${prefix}:auth:ratelimit:${key}`}}async get(key){const resolved=await this.key(key),raw=await resolved.client.get(resolved.key);if(raw==null)return;try{const entry=JSON.parse(raw);if(!Number.isSafeInteger(entry.attempts)||entry.attempts<0||!Number.isSafeInteger(entry.lockedUntil)||entry.lockedUntil<0)return;return{attempts:entry.attempts,lockedUntil:entry.lockedUntil}}catch{return}}async set(key,entry,ttlMs){const resolved=await this.key(key);await resolved.client.send("SET",[resolved.key,JSON.stringify(entry),"PX",String(ttlMs)])}async delete(key){const resolved=await this.key(key);await resolved.client.del(resolved.key)}async recordFailedAttempt(key,now,ttlMs){const resolved=await this.key(key);await resolved.client.send("EVAL",[RECORD_FAILURE_SCRIPT,"1",resolved.key,String(now),String(ttlMs),String(MAX_ATTEMPTS)])}async close(){if(!this.clientPromise)return;const{client}=await this.clientPromise;client.close();this.clientPromise=void 0}}let store;function replaceStore(next){const previous=store;store=next;if(previous&&previous!==next&&previous.close)Promise.resolve(previous.close()).catch(()=>{})}async function configuredStore(){if(store)return store;const{cache}=await import("@stacksjs/config");store??=cache.driver==="redis"?new RedisRateLimiterStore:new MemoryStore;return store}export class RateLimiter{static useStore(custom){replaceStore(custom)}static useSharedStore(options={}){replaceStore(new RedisRateLimiterStore(options))}static useMemoryStore(){replaceStore(new MemoryStore)}static useConfiguredStore(){replaceStore(void 0)}static async isRateLimited(email){email=email.toLowerCase();const now=Date.now(),userAttempts=await(await configuredStore()).get(email);if(!userAttempts)return!1;return userAttempts.lockedUntil>now}static async recordFailedAttempt(email){email=email.toLowerCase();const now=Date.now();await(await configuredStore()).recordFailedAttempt(email,now,LOCKOUT_DURATION)}static async resetAttempts(email){await(await configuredStore()).delete(email.toLowerCase())}static async validateAttempt(email){if(await this.isRateLimited(email))throw new HttpError(429,"Too many login attempts. Please try again later.")}}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.28",
5
+ "version": "0.75.29",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.18",
61
- "@stacksjs/cache": "0.75.28",
62
- "@stacksjs/config": "0.75.28",
63
- "@stacksjs/database": "0.75.28",
64
- "@stacksjs/email": "0.75.28",
65
- "@stacksjs/env": "0.75.28",
66
- "@stacksjs/error-handling": "0.75.28",
67
- "@stacksjs/logging": "0.75.28",
68
- "@stacksjs/orm": "0.75.28",
69
- "@stacksjs/path": "0.75.28",
70
- "@stacksjs/router": "0.75.28",
71
- "@stacksjs/security": "0.75.28",
72
- "@stacksjs/storage": "0.75.28",
61
+ "@stacksjs/cache": "0.75.29",
62
+ "@stacksjs/config": "0.75.29",
63
+ "@stacksjs/database": "0.75.29",
64
+ "@stacksjs/email": "0.75.29",
65
+ "@stacksjs/env": "0.75.29",
66
+ "@stacksjs/error-handling": "0.75.29",
67
+ "@stacksjs/logging": "0.75.29",
68
+ "@stacksjs/orm": "0.75.29",
69
+ "@stacksjs/path": "0.75.29",
70
+ "@stacksjs/router": "0.75.29",
71
+ "@stacksjs/security": "0.75.29",
72
+ "@stacksjs/storage": "0.75.29",
73
73
  "@stacksjs/ts-auth": "^0.4.4",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },