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