@stacksjs/auth 0.75.17 → 0.75.19
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/dist/index.d.ts +4 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-endpoint.d.ts +58 -0
- package/dist/oauth-authorization-endpoint.js +1 -0
- package/dist/oauth-authorization-requests.d.ts +1 -0
- package/dist/oauth-authorization-requests.js +3 -3
- package/dist/oauth-authorization-response.d.ts +6 -0
- package/dist/oauth-authorization-response.js +1 -0
- package/dist/oauth-authorization.d.ts +4 -0
- package/dist/oauth-authorization.js +1 -1
- package/dist/oauth-browser-session.d.ts +15 -0
- package/dist/oauth-browser-session.js +1 -0
- package/dist/oauth-client-registration.d.ts +5 -0
- package/dist/oauth-client-registration.js +2 -2
- package/dist/oauth-consent.d.ts +37 -1
- package/dist/oauth-consent.js +2 -2
- package/dist/oauth-metadata.d.ts +15 -0
- package/dist/oauth-metadata.js +1 -0
- package/dist/oauth-provider.d.ts +1 -0
- package/dist/oauth-provider.js +1 -1
- package/package.json +13 -13
package/dist/index.d.ts
CHANGED
|
@@ -4,12 +4,16 @@ export * from './authenticator';
|
|
|
4
4
|
export * from './client';
|
|
5
5
|
export * from './middleware';
|
|
6
6
|
export * from './oauth-authorization';
|
|
7
|
+
export * from './oauth-authorization-endpoint';
|
|
7
8
|
export * from './oauth-authorization-requests';
|
|
9
|
+
export * from './oauth-authorization-response';
|
|
8
10
|
export * from './oauth-authorization-codes';
|
|
11
|
+
export * from './oauth-browser-session';
|
|
9
12
|
export * from './oauth-client-registration';
|
|
10
13
|
export * from './oauth-consent';
|
|
11
14
|
export * from './oauth-delegated-access';
|
|
12
15
|
export * from './oauth-grants';
|
|
16
|
+
export * from './oauth-metadata';
|
|
13
17
|
export * from './oauth-provider';
|
|
14
18
|
export * from './oauth-pkce';
|
|
15
19
|
export * from './oauth-revocation';
|
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-requests";export*from"./oauth-authorization-codes";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";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-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";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { loadOAuthAuthorizationConsentView } from './oauth-consent';
|
|
2
|
+
import type { OAuthAuthorizationConsentView } from './oauth-consent';
|
|
3
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
4
|
+
/**
|
|
5
|
+
* Start or resume the browser authorization page without registering a route.
|
|
6
|
+
*
|
|
7
|
+
* Login receives only a local resume URL carrying the opaque request handle.
|
|
8
|
+
* Callback, PKCE, client state, subject, and workspace authority remain in the
|
|
9
|
+
* database record bound to the HttpOnly browser-session cookie.
|
|
10
|
+
*/
|
|
11
|
+
export declare function handleOAuthAuthorizationPageRequest(input: HandleOAuthAuthorizationPageRequestInput): Promise<Response>;
|
|
12
|
+
/** Validate and persist one browser authorization request behind an opaque handle. */
|
|
13
|
+
export declare function beginOAuthAuthorizationRequest(input: BeginOAuthAuthorizationRequestInput): Promise<BegunOAuthAuthorizationRequest>;
|
|
14
|
+
/**
|
|
15
|
+
* Complete one authenticated, CSRF-verified consent form submission.
|
|
16
|
+
*
|
|
17
|
+
* Identity and workspace values are server-derived inputs. The browser body
|
|
18
|
+
* contributes only the opaque request handle and approve or deny decision.
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleOAuthAuthorizationConsentRequest(input: HandleOAuthAuthorizationConsentRequestInput): Promise<Response>;
|
|
21
|
+
export declare interface BeginOAuthAuthorizationRequestInput {
|
|
22
|
+
provider: ResolvedOAuthProviderConfig
|
|
23
|
+
query: string | URLSearchParams
|
|
24
|
+
browserSessionId: string
|
|
25
|
+
}
|
|
26
|
+
export declare interface BegunOAuthAuthorizationRequest {
|
|
27
|
+
requestId: string
|
|
28
|
+
}
|
|
29
|
+
export declare interface HandleOAuthAuthorizationConsentRequestInput {
|
|
30
|
+
provider: ResolvedOAuthProviderConfig
|
|
31
|
+
request: Request
|
|
32
|
+
subjectType: string
|
|
33
|
+
subjectId: number
|
|
34
|
+
workspaceId?: string | null
|
|
35
|
+
}
|
|
36
|
+
export declare interface OAuthAuthorizationPageIdentity {
|
|
37
|
+
label: string
|
|
38
|
+
workspaceLabel?: string | null
|
|
39
|
+
}
|
|
40
|
+
export declare interface OAuthAuthorizationConsentPageContext {
|
|
41
|
+
consent: OAuthAuthorizationConsentView
|
|
42
|
+
signedInIdentity: string
|
|
43
|
+
selectedWorkspace: string | null
|
|
44
|
+
consentAction: string
|
|
45
|
+
csrfToken: string
|
|
46
|
+
}
|
|
47
|
+
export declare interface OAuthAuthorizationPageDependencies {
|
|
48
|
+
begin?: typeof beginOAuthAuthorizationRequest
|
|
49
|
+
loadConsent?: typeof loadOAuthAuthorizationConsentView
|
|
50
|
+
render: (view: string, context: OAuthAuthorizationConsentPageContext) => Promise<string>
|
|
51
|
+
}
|
|
52
|
+
export declare interface HandleOAuthAuthorizationPageRequestInput {
|
|
53
|
+
provider: ResolvedOAuthProviderConfig
|
|
54
|
+
request: Request
|
|
55
|
+
identity: OAuthAuthorizationPageIdentity | null
|
|
56
|
+
csrfToken?: string
|
|
57
|
+
dependencies: OAuthAuthorizationPageDependencies
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{createOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest}from"./oauth-consent";import{isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await input.request.text())}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);throw error}const browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value):localAuthorizationError(400)}let approved;try{approved=await approveOAuthAuthorizationRequestSession({provider:input.provider,requestId:consent.requestId,browserSessionId,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId:input.workspaceId,authorizationCodeLifetimeMs:input.provider.lifetimes.authorizationCode})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value):localAuthorizationError(400)}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
|
|
2
|
+
export declare function isOAuthAuthorizationRequestId(value: string): boolean;
|
|
2
3
|
/** Store one validated authorization request behind an opaque, hashed handle. */
|
|
3
4
|
export declare function createOAuthAuthorizationRequestSession(request: ValidatedOAuthAuthorizationRequest, browserSessionId: string, lifetimeMs: number): Promise<string>;
|
|
4
5
|
/** Reload a request only for its original browser and current client policy. */
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function includesAll(allowed,requested){return requested.every((value)=>allowed.includes(value))}function inactive(value){return value===!1||value===0}function requestFromStoredRow(row,now){const clientId=Number(row.client_id);if(!Number.isSafeInteger(clientId)||clientId<=0||row.client_type!=="public"&&row.client_type!=="confidential"||!isValidOAuthRedirectUri(row.redirect_uri)||row.code_challenge_method!=="S256"||!isValidS256CodeChallenge(row.code_challenge)||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=now||row.consumed_at!=null||row.state!=null&&row.state.length>4096)return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes||!resources||!audiences||resources.length!==audiences.length)return null;return{responseType:"code",clientId:String(clientId),clientType:row.client_type,redirectUri:row.redirect_uri,scopes,resources,audiences,state:row.state,codeChallenge:row.code_challenge,codeChallengeMethod:"S256"}}function policyAllowsRequest(row,request){if(!row||String(row.id)!==request.clientId)return!1;const redirectUris=storedValues(row.redirect_uris),grantTypes=storedValues(row.grant_types),scopes=storedValues(row.allowed_scopes),resources=storedValues(row.allowed_resources),method=row.token_endpoint_auth_method;if(!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources)return!1;return row.client_type===request.clientType&&inactive(row.revoked)&&inactive(row.personal_access_client)&&inactive(row.password_client)&&(row.client_type==="public"&&method==="none"&&row.secret==null||row.client_type==="confidential"&&method==="client_secret_basic"&&Boolean(row.secret))&&redirectUris.includes(request.redirectUri)&&row.redirect===redirectUris[0]&&new Set(redirectUris).size===redirectUris.length&&new Set(grantTypes).size===grantTypes.length&&new Set(scopes).size===scopes.length&&new Set(resources).size===resources.length&&redirectUris.every(isValidOAuthRedirectUri)&&includesAll(scopes,request.scopes)&&includesAll(resources,request.resources)}export async function createOAuthAuthorizationRequestSession(request,browserSessionId,lifetimeMs){const clientId=Number(request.clientId);if(!Number.isSafeInteger(clientId)||clientId<=0)throw TypeError("OAuth authorization request client identifier is invalid.");if(!validBrowserSession(browserSessionId))throw TypeError("OAuth authorization request requires a valid browser session identifier.");if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth authorization request lifetime must be a positive safe integer.");if(request.state!=null&&request.state.length>4096)throw TypeError("OAuth authorization request state exceeds 4096 characters.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};export function isOAuthAuthorizationRequestId(value){return REQUEST_ID_PATTERN.test(value)}function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function includesAll(allowed,requested){return requested.every((value)=>allowed.includes(value))}function inactive(value){return value===!1||value===0}function requestFromStoredRow(row,now){const clientId=Number(row.client_id);if(!Number.isSafeInteger(clientId)||clientId<=0||row.client_type!=="public"&&row.client_type!=="confidential"||!isValidOAuthRedirectUri(row.redirect_uri)||row.code_challenge_method!=="S256"||!isValidS256CodeChallenge(row.code_challenge)||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=now||row.consumed_at!=null||row.state!=null&&row.state.length>4096)return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes||!resources||!audiences||resources.length!==audiences.length)return null;return{responseType:"code",clientId:String(clientId),clientType:row.client_type,redirectUri:row.redirect_uri,scopes,resources,audiences,state:row.state,codeChallenge:row.code_challenge,codeChallengeMethod:"S256"}}function policyAllowsRequest(row,request){if(!row||String(row.id)!==request.clientId)return!1;const redirectUris=storedValues(row.redirect_uris),grantTypes=storedValues(row.grant_types),scopes=storedValues(row.allowed_scopes),resources=storedValues(row.allowed_resources),method=row.token_endpoint_auth_method;if(!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources)return!1;return row.client_type===request.clientType&&inactive(row.revoked)&&inactive(row.personal_access_client)&&inactive(row.password_client)&&(row.client_type==="public"&&method==="none"&&row.secret==null||row.client_type==="confidential"&&method==="client_secret_basic"&&Boolean(row.secret))&&redirectUris.includes(request.redirectUri)&&row.redirect===redirectUris[0]&&new Set(redirectUris).size===redirectUris.length&&new Set(grantTypes).size===grantTypes.length&&new Set(scopes).size===scopes.length&&new Set(resources).size===resources.length&&redirectUris.every(isValidOAuthRedirectUri)&&includesAll(scopes,request.scopes)&&includesAll(resources,request.resources)}export async function createOAuthAuthorizationRequestSession(request,browserSessionId,lifetimeMs){const clientId=Number(request.clientId);if(!Number.isSafeInteger(clientId)||clientId<=0)throw TypeError("OAuth authorization request client identifier is invalid.");if(!validBrowserSession(browserSessionId))throw TypeError("OAuth authorization request requires a valid browser session identifier.");if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth authorization request lifetime must be a positive safe integer.");if(request.state!=null&&request.state.length>4096)throw TypeError("OAuth authorization request state exceeds 4096 characters.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
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
|
-
`,values.values);markContextWrote();return id}export async function loadOAuthAuthorizationRequestSession(requestId,browserSessionId){if(!
|
|
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
8
|
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
|
|
9
9
|
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
10
10
|
FROM oauth_authorization_requests
|
|
@@ -13,7 +13,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
13
13
|
AND consumed_at IS NULL
|
|
14
14
|
AND expires_at > ${sql.param(3)}
|
|
15
15
|
LIMIT 1
|
|
16
|
-
`,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!
|
|
16
|
+
`,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return invalidRequest;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=new Date,nowSql=sqlDateTime(now),rows=await trx.unsafe(`
|
|
17
17
|
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
|
|
18
18
|
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
19
19
|
FROM oauth_authorization_requests
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { OAuthAuthorizationRequestError } from './oauth-authorization';
|
|
2
|
+
import type { OAuthAuthorizationConsentResult, OAuthAuthorizationDenialResult } from './oauth-consent';
|
|
3
|
+
/** Redirect an approved or denied consent result to its already validated callback. */
|
|
4
|
+
export declare function oauthAuthorizationConsentResponse(result: OAuthAuthorizationConsentResult | OAuthAuthorizationDenialResult): Response;
|
|
5
|
+
/** Redirect a protocol failure only when validation produced a trusted callback. */
|
|
6
|
+
export declare function oauthAuthorizationRequestErrorResponse(error: OAuthAuthorizationRequestError): Response | null;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isValidOAuthRedirectUri,OAuthAuthorizationRequestError}from"./oauth-authorization";const AUTHORIZATION_REDIRECT_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"},RESPONSE_PARAMETERS=["code","error","error_description","error_uri","state"];function redirectResponse(redirectUri,result,state){if(!isValidOAuthRedirectUri(redirectUri))throw TypeError("OAuth authorization response requires a safe redirect URI.");const target=new URL(redirectUri);for(const parameter of RESPONSE_PARAMETERS)target.searchParams.delete(parameter);if("code"in result)target.searchParams.set("code",result.code);else target.searchParams.set("error",result.error);if(state!==null)target.searchParams.set("state",state);return new Response(null,{status:302,headers:{...AUTHORIZATION_REDIRECT_HEADERS,Location:target.toString()}})}export function oauthAuthorizationConsentResponse(result){return"code"in result?redirectResponse(result.redirectUri,{code:result.code},result.state):redirectResponse(result.redirectUri,{error:result.error},result.state)}export function oauthAuthorizationRequestErrorResponse(error){if(error.redirectUri===null)return null;return redirectResponse(error.redirectUri,{error:error.code},error.state)}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** Parse the browser query without trusting any callback URL it contains. */
|
|
3
|
+
export declare function parseOAuthAuthorizationRequest(query: string | URLSearchParams): OAuthAuthorizationRequestInput;
|
|
2
4
|
/** Whether a registered callback is safe for exact redirect matching. */
|
|
3
5
|
export declare function isValidOAuthRedirectUri(value: string): boolean;
|
|
6
|
+
/** Recheck provider-owned policy immediately before consent creates a grant. */
|
|
7
|
+
export declare function assertOAuthAuthorizationProviderPolicy(provider: ResolvedOAuthProviderConfig, request: ValidatedOAuthAuthorizationRequest): void;
|
|
4
8
|
/**
|
|
5
9
|
* Validate the complete, browser-facing authorization request boundary.
|
|
6
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)}export function isValidOAuthRedirectUri(value){if(value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}function requestedScopes(value,redirectUri,state){if(value==null||value==="")return[];const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!
|
|
1
|
+
import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}function scalarParameter(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth authorization parameter must not be repeated: ${name}`);if(required&&(!values.length||values[0]===""))reject("invalid_request",`OAuth authorization parameter is required: ${name}`);return values[0]}export function parseOAuthAuthorizationRequest(query){const encoded=typeof query==="string"?query.replace(/^\?/,""):query.toString();if(encoded.length>8192)reject("invalid_request","OAuth authorization request is too large.");const params=typeof query==="string"?new URLSearchParams(encoded):query,responseType=scalarParameter(params,"response_type",!0),clientId=scalarParameter(params,"client_id",!0),redirectUri=scalarParameter(params,"redirect_uri",!0),scope=scalarParameter(params,"scope"),state=scalarParameter(params,"state"),codeChallenge=scalarParameter(params,"code_challenge",!0),codeChallengeMethod=scalarParameter(params,"code_challenge_method",!0),resources=params.getAll("resource");return{responseType,clientId,redirectUri,codeChallenge,codeChallengeMethod,...scope===void 0?{}:{scope},...resources.length?{resource:resources}:{},...state===void 0?{}:{state}}}export function isValidOAuthRedirectUri(value){if(value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}function requestedScopes(value,redirectUri,state){if(value==null||value==="")return[];const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function assertOAuthAuthorizationProviderPolicy(provider,request){if(!provider.clientTypes.includes(request.clientType))reject("unauthorized_client","OAuth client type is no longer enabled by this provider.",request.redirectUri,request.state);for(const scope of request.scopes)if(!provider.scopes[scope])reject("invalid_scope",`OAuth scope is no longer registered by this provider: ${scope}`,request.redirectUri,request.state);if(request.resources.length!==request.audiences.length)reject("invalid_target","OAuth resource bindings are invalid.",request.redirectUri,request.state);for(const[index,resource]of request.resources.entries())if(provider.resources[resource]?.audience!==request.audiences[index])reject("invalid_target",`OAuth resource is no longer registered by this provider: ${resource}`,request.redirectUri,request.state);for(const scope of request.scopes){const allowedResources=provider.scopes[scope]?.resources;if(!allowedResources?.length)continue;if(!request.resources.length||request.resources.some((resource)=>!allowedResources.includes(resource)))reject("invalid_target",`OAuth scope is not valid for the requested resource: ${scope}`,request.redirectUri,request.state)}}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!client.scopes.includes(scope))reject("invalid_scope",`OAuth scope is not registered for this client: ${scope}`,redirectUri,state);const audiences=requestedAudiences(input.resource,redirectUri,state),resources=[];for(const audience of audiences){const matchingResources=Object.entries(provider.resources).filter(([,resource])=>resource.audience===audience).map(([key])=>key);if(matchingResources.length!==1||!client.resources.includes(matchingResources[0]))reject("invalid_target",`OAuth resource is not registered for this client: ${audience}`,redirectUri,state);resources.push(matchingResources[0])}const validated={responseType:"code",clientId:input.clientId,clientType:client.type,redirectUri,scopes,resources,audiences,state,codeChallenge:input.codeChallenge,codeChallengeMethod:"S256"};assertOAuthAuthorizationProviderPolicy(provider,validated);return validated}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** Cookie name is `__Host-` protected except on permitted loopback HTTP. */
|
|
3
|
+
export declare function oauthAuthorizationBrowserSessionCookieName(provider: ResolvedOAuthProviderConfig): string;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the stable, pre-auth browser binding used through login and 2FA.
|
|
6
|
+
*
|
|
7
|
+
* The browser never submits OAuth callback or identity authority. It carries
|
|
8
|
+
* only this random HttpOnly value while the database stores its SHA-256 hash.
|
|
9
|
+
* Reissuing the cookie refreshes its lifetime for a newly started request.
|
|
10
|
+
*/
|
|
11
|
+
export declare function oauthAuthorizationBrowserSession(request: Request, provider: ResolvedOAuthProviderConfig): OAuthAuthorizationBrowserSession;
|
|
12
|
+
export declare interface OAuthAuthorizationBrowserSession {
|
|
13
|
+
id: string
|
|
14
|
+
cookie: string
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{randomBytes}from"node:crypto";const SESSION_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,HTTPS_COOKIE_NAME="__Host-stacks-oauth-session",LOOPBACK_COOKIE_NAME="stacks-oauth-session";function isHttps(provider){return new URL(provider.issuer).protocol==="https:"}export function oauthAuthorizationBrowserSessionCookieName(provider){return isHttps(provider)?HTTPS_COOKIE_NAME:LOOPBACK_COOKIE_NAME}function requestSessionId(request,name){const matches=(request.headers.get("cookie")??"").split(";").map((cookie)=>cookie.trim()).filter(Boolean).flatMap((cookie)=>{const separator=cookie.indexOf("=");if(separator<=0||cookie.slice(0,separator).trim()!==name)return[];return[cookie.slice(separator+1).trim()]});return matches.length===1&&SESSION_ID_PATTERN.test(matches[0])?matches[0]:null}export function oauthAuthorizationBrowserSession(request,provider){const name=oauthAuthorizationBrowserSessionCookieName(provider),id=requestSessionId(request,name)??randomBytes(32).toString("base64url"),maxAge=Math.ceil(provider.lifetimes.authorizationRequest/1000);if(!Number.isSafeInteger(maxAge)||maxAge<=0)throw TypeError("OAuth authorization request lifetime cannot be represented as cookie seconds.");return{id,cookie:[`${name}=${id}`,"Path=/",`Max-Age=${maxAge}`,"HttpOnly","SameSite=Lax",...isHttps(provider)?["Secure"]:[]].join("; ")}}
|
|
@@ -5,6 +5,8 @@ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
|
5
5
|
export declare function validateOAuthClientRegistration(provider: ResolvedOAuthProviderConfig, input: OAuthClientRegistrationInput): ValidatedOAuthClientRegistration;
|
|
6
6
|
/** Load only complete provider registration metadata from authoritative storage. */
|
|
7
7
|
export declare function loadOAuthAuthorizationClient(clientId: string): Promise<OAuthAuthorizationClientRegistration | null>;
|
|
8
|
+
/** Load validated client policy plus the display name used during consent. */
|
|
9
|
+
export declare function loadOAuthAuthorizationClientDetails(clientId: string): Promise<OAuthAuthorizationClientDetails | null>;
|
|
8
10
|
/** List complete provider clients owned by one account without exposing secret hashes. */
|
|
9
11
|
export declare function listOAuthClients(ownerId: number): Promise<ManagedOAuthClient[]>;
|
|
10
12
|
/** Edit one active owner-managed client without changing its authentication class. */
|
|
@@ -58,6 +60,9 @@ export declare interface RegisteredOAuthClientResult {
|
|
|
58
60
|
client: RegisteredOAuthClient
|
|
59
61
|
plainTextSecret?: string
|
|
60
62
|
}
|
|
63
|
+
export declare interface OAuthAuthorizationClientDetails extends OAuthAuthorizationClientRegistration {
|
|
64
|
+
name: string
|
|
65
|
+
}
|
|
61
66
|
export declare interface ManagedOAuthClient {
|
|
62
67
|
id: number
|
|
63
68
|
ownerId: number
|
|
@@ -21,11 +21,11 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
21
21
|
SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
22
22
|
WHERE oauth_client_id = ${sql.param(3)}
|
|
23
23
|
`,[revoked,now,clientId])}function storedFlag(value){if(value===!1||value===0)return!1;if(value===!0||value===1)return!0;return null}function authorizationClientFromStored(row,id){const redirectUris=storedValues(row?.redirect_uris??null),grantTypes=storedValues(row?.grant_types??null),scopes=storedValues(row?.allowed_scopes??null),resources=storedValues(row?.allowed_resources??null),revoked=row?storedFlag(row.revoked):null,type=row?.client_type,method=row?.token_endpoint_auth_method;if(!row||String(row.id)!==String(id)||type!=="public"&&type!=="confidential"||method!=="none"&&method!=="client_secret_basic"||(type==="public"?method!=="none"||row.secret!=null:method!=="client_secret_basic"||!row.secret)||!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources||new Set(redirectUris).size!==redirectUris.length||new Set(grantTypes).size!==grantTypes.length||new Set(scopes).size!==scopes.length||new Set(resources).size!==resources.length||redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri))||row.redirect!==redirectUris[0]||!inactive(row.personal_access_client)||!inactive(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}async function storedAuthorizationClient(clientId){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
|
|
24
|
-
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
24
|
+
SELECT id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
25
25
|
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
26
26
|
personal_access_client, password_client, revoked
|
|
27
27
|
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
|
|
28
|
-
`,[id]))[0],client=authorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
28
|
+
`,[id]))[0],client=authorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function loadOAuthAuthorizationClientDetails(clientId){const stored=await storedAuthorizationClient(clientId),name=typeof stored?.row.name==="string"?stored.row.name.trim():"";if(!stored||!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))return null;return{...stored.client,name}}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
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
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { type ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
|
|
1
2
|
import type { OAuthAuthorizationRequestSessionResult } from './oauth-authorization-requests';
|
|
2
|
-
import type {
|
|
3
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
4
|
+
/** Parse only the opaque handle and decision from a CSRF-verified form body. */
|
|
5
|
+
export declare function parseOAuthAuthorizationConsentRequest(body: string | URLSearchParams): OAuthAuthorizationConsentRequest;
|
|
6
|
+
/** Build consent-safe display data without returning callback or PKCE fields. */
|
|
7
|
+
export declare function loadOAuthAuthorizationConsentView(input: LoadOAuthAuthorizationConsentViewInput): Promise<OAuthAuthorizationConsentView | null>;
|
|
3
8
|
/** Whether a recent active grant already covers the requested consent. */
|
|
4
9
|
export declare function hasReusableOAuthConsent(input: ReusableOAuthConsentInput): Promise<boolean>;
|
|
5
10
|
/** Atomically turn one authenticated browser approval into a grant and code. */
|
|
@@ -7,6 +12,7 @@ export declare function approveOAuthAuthorizationRequestSession(input: ApproveOA
|
|
|
7
12
|
/** Consume one browser-bound denial without creating a grant or authorization code. */
|
|
8
13
|
export declare function denyOAuthAuthorizationRequestSession(requestId: string, browserSessionId: string): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationDenialResult>>;
|
|
9
14
|
export declare interface ApproveOAuthAuthorizationRequestSessionInput {
|
|
15
|
+
provider: ResolvedOAuthProviderConfig
|
|
10
16
|
requestId: string
|
|
11
17
|
browserSessionId: string
|
|
12
18
|
subjectType: string
|
|
@@ -32,3 +38,33 @@ export declare interface ReusableOAuthConsentInput {
|
|
|
32
38
|
workspaceId?: string | null
|
|
33
39
|
rememberForMs: number
|
|
34
40
|
}
|
|
41
|
+
export declare interface LoadOAuthAuthorizationConsentViewInput {
|
|
42
|
+
provider: ResolvedOAuthProviderConfig
|
|
43
|
+
requestId: string
|
|
44
|
+
browserSessionId: string
|
|
45
|
+
}
|
|
46
|
+
export declare interface OAuthAuthorizationConsentView {
|
|
47
|
+
requestId: string
|
|
48
|
+
client: {
|
|
49
|
+
id: string
|
|
50
|
+
name: string
|
|
51
|
+
type: 'confidential' | 'public'
|
|
52
|
+
}
|
|
53
|
+
permissions: Array<{
|
|
54
|
+
name: string
|
|
55
|
+
description: string
|
|
56
|
+
}>
|
|
57
|
+
resources: Array<{
|
|
58
|
+
name: string
|
|
59
|
+
audience: string
|
|
60
|
+
description?: string
|
|
61
|
+
}>
|
|
62
|
+
}
|
|
63
|
+
export declare interface OAuthAuthorizationConsentRequest {
|
|
64
|
+
requestId: string
|
|
65
|
+
decision: 'approve' | 'deny'
|
|
66
|
+
}
|
|
67
|
+
export declare class OAuthAuthorizationConsentRequestError extends Error {
|
|
68
|
+
readonly code: 'invalid_request';
|
|
69
|
+
constructor(message: string);
|
|
70
|
+
}
|
package/dist/oauth-consent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";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]})}export async function hasReusableOAuthConsent(input){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return!1;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return!1;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return!1;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`;return(await db.primary.unsafe(`
|
|
1
|
+
import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}export async function hasReusableOAuthConsent(input){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return!1;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return!1;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return!1;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`;return(await db.primary.unsafe(`
|
|
2
2
|
SELECT 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}
|
|
@@ -9,4 +9,4 @@ import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stack
|
|
|
9
9
|
AND g.created_at >= ${sql.param(4)}
|
|
10
10
|
AND ${workspacePredicate}
|
|
11
11
|
ORDER BY g.created_at DESC, g.id DESC
|
|
12
|
-
`,params)).some((row)=>{const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);return(parseSqlDateTime(row.created_at)?.getTime()??0)>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)})}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request)=>{const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId:input.workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
|
|
12
|
+
`,params)).some((row)=>{const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);return(parseSqlDateTime(row.created_at)?.getTime()??0)>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)})}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId:input.workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** Build RFC 8414 metadata from the provider profile Stacks actually serves. */
|
|
3
|
+
export declare function oauthAuthorizationServerMetadata(provider: ResolvedOAuthProviderConfig): OAuthAuthorizationServerMetadata;
|
|
4
|
+
export declare interface OAuthAuthorizationServerMetadata {
|
|
5
|
+
issuer: string
|
|
6
|
+
authorization_endpoint: string
|
|
7
|
+
token_endpoint: string
|
|
8
|
+
revocation_endpoint: string
|
|
9
|
+
response_types_supported: string[]
|
|
10
|
+
grant_types_supported: string[]
|
|
11
|
+
code_challenge_methods_supported: string[]
|
|
12
|
+
token_endpoint_auth_methods_supported: string[]
|
|
13
|
+
revocation_endpoint_auth_methods_supported: string[]
|
|
14
|
+
scopes_supported: string[]
|
|
15
|
+
}
|
|
@@ -0,0 +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()}}
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export declare interface ResolvedOAuthProviderConfig {
|
|
|
25
25
|
scopes: Record<string, OAuthProviderScopeConfig>
|
|
26
26
|
resources: Record<string, OAuthProviderResourceConfig>
|
|
27
27
|
lifetimes: {
|
|
28
|
+
authorizationRequest: number
|
|
28
29
|
authorizationCode: number
|
|
29
30
|
accessToken: number
|
|
30
31
|
refreshToken: number
|
package/dist/oauth-provider.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const DEFAULT_LIFETIMES={authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"};function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function positiveLifetime(name,value){if(!Number.isFinite(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive number of milliseconds.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isFinite(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative number of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");return{rememberFor,view}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)};return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints:{authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)},responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
|
|
1
|
+
const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"};function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function positiveLifetime(name,value){if(!Number.isFinite(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive number of milliseconds.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isFinite(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative number of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");return{rememberFor,view}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)};return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints:{authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)},responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
|
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.19",
|
|
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.19",
|
|
62
|
+
"@stacksjs/config": "0.75.19",
|
|
63
|
+
"@stacksjs/database": "0.75.19",
|
|
64
|
+
"@stacksjs/email": "0.75.19",
|
|
65
|
+
"@stacksjs/env": "0.75.19",
|
|
66
|
+
"@stacksjs/error-handling": "0.75.19",
|
|
67
|
+
"@stacksjs/logging": "0.75.19",
|
|
68
|
+
"@stacksjs/orm": "0.75.19",
|
|
69
|
+
"@stacksjs/path": "0.75.19",
|
|
70
|
+
"@stacksjs/router": "0.75.19",
|
|
71
|
+
"@stacksjs/security": "0.75.19",
|
|
72
|
+
"@stacksjs/storage": "0.75.19",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|