@stacksjs/auth 0.75.191 → 0.75.192

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 CHANGED
@@ -68,3 +68,6 @@ export * from './two-factor';
68
68
  export * from './team';
69
69
  // Referral codes, signup attribution, and server-verified conversions.
70
70
  export * from './referrals';
71
+ export * from './oauth-dynamic-registration';
72
+ export * from './oauth-protected-resource';
73
+ export * from './oauth-server';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";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-introspection";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"./passkeys";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"./credential-user";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-introspection";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"./passkeys";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";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
@@ -16,6 +16,7 @@ export declare interface AuthorizationCodeRedemption {
16
16
  clientId: number
17
17
  redirectUri: string
18
18
  codeVerifier: string
19
+ audiences?: readonly string[]
19
20
  }
20
21
  export declare interface AuthorizationCodeGrant {
21
22
  grantId: string
@@ -20,7 +20,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
20
20
  JOIN oauth_grants g ON g.id = c.grant_id AND g.revoked_at IS NULL
21
21
  WHERE c.code_hash = ${sql.param(1)}
22
22
  LIMIT 1${lock}
23
- `,[hash]))[0];if(!row||row.consumed_at!=null||String(row.client_id)!==String(expected.clientId)||row.redirect_uri!==expected.redirectUri||row.code_challenge_method!=="S256"||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now()||!await verifyS256CodeChallenge(expected.codeVerifier,row.code_challenge))return invalidGrant;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),grantScopes=storedValues(row.grant_scopes),grantResources=storedValues(row.grant_resources),grantAudiences=storedValues(row.grant_audiences),clientId=Number(row.client_id),subjectId=Number(row.subject_id);if(!row.grant_id||!scopes||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||!validIdentifier(clientId)||!validIdentifier(subjectId)||String(row.grant_client_id)!==String(row.client_id)||row.grant_subject_type!==row.subject_type||String(row.grant_subject_id)!==String(row.subject_id)||JSON.stringify(grantScopes)!==JSON.stringify(scopes)||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||row.grant_workspace_id!==row.workspace_id)return invalidGrant;const now=sqlDateTime(new Date),claimed=await trx.unsafe(`
23
+ `,[hash]))[0];if(!row||expected.audiences&&(!expected.audiences.length||new Set(expected.audiences).size!==expected.audiences.length||expected.audiences.length!==storedValues(row.audiences)?.length||expected.audiences.some((audience)=>!storedValues(row.audiences)?.includes(audience)))||row.consumed_at!=null||String(row.client_id)!==String(expected.clientId)||row.redirect_uri!==expected.redirectUri||row.code_challenge_method!=="S256"||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now()||!await verifyS256CodeChallenge(expected.codeVerifier,row.code_challenge))return invalidGrant;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),grantScopes=storedValues(row.grant_scopes),grantResources=storedValues(row.grant_resources),grantAudiences=storedValues(row.grant_audiences),clientId=Number(row.client_id),subjectId=Number(row.subject_id);if(!row.grant_id||!scopes||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||!validIdentifier(clientId)||!validIdentifier(subjectId)||String(row.grant_client_id)!==String(row.client_id)||row.grant_subject_type!==row.subject_type||String(row.grant_subject_id)!==String(row.subject_id)||JSON.stringify(grantScopes)!==JSON.stringify(scopes)||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||row.grant_workspace_id!==row.workspace_id)return invalidGrant;const now=sqlDateTime(new Date),claimed=await trx.unsafe(`
24
24
  UPDATE oauth_auth_codes
25
25
  SET consumed_at = ${sql.param(1)}
26
26
  WHERE code_hash = ${sql.param(2)}
@@ -1 +1 @@
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
+ import{readTextBody,RequestBodyError}from"@stacksjs/router/body";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,input.provider.issuer);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,input.provider.issuer),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,input.provider.issuer);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 readTextBody(input.request,4096))}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);if(error instanceof RequestBodyError)return localAuthorizationError(error.status);throw error}const json=input.request.headers.get("accept")?.split(",").some((value)=>value.trim().split(";")[0]==="application/json")===!0,browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value,input.provider.issuer,{json}):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,approvedScopes:consent.approvedScopes})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error,input.provider.issuer)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value,input.provider.issuer,{json}):localAuthorizationError(400)}
@@ -1,6 +1,6 @@
1
1
  import { OAuthAuthorizationRequestError } from './oauth-authorization';
2
2
  import type { OAuthAuthorizationConsentResult, OAuthAuthorizationDenialResult } from './oauth-consent';
3
3
  /** Redirect an approved or denied consent result to its already validated callback. */
4
- export declare function oauthAuthorizationConsentResponse(result: OAuthAuthorizationConsentResult | OAuthAuthorizationDenialResult): Response;
4
+ export declare function oauthAuthorizationConsentResponse(result: OAuthAuthorizationConsentResult | OAuthAuthorizationDenialResult, issuer?: string, options?: { json?: boolean }): Response;
5
5
  /** Redirect a protocol failure only when validation produced a trusted callback. */
6
- export declare function oauthAuthorizationRequestErrorResponse(error: OAuthAuthorizationRequestError): Response | null;
6
+ export declare function oauthAuthorizationRequestErrorResponse(error: OAuthAuthorizationRequestError, issuer?: string): Response | null;
@@ -1 +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
+ import{isValidOAuthRedirectUri,OAuthAuthorizationRequestError}from"./oauth-authorization";const AUTHORIZATION_REDIRECT_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"},RESPONSE_PARAMETERS=["iss","code","error","error_description","error_uri","state"];function redirectResponse(redirectUri,result,state,issuer){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(issuer)target.searchParams.set("iss",issuer);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,issuer,options={}){const redirect="code"in result?redirectResponse(result.redirectUri,{code:result.code},result.state,issuer):redirectResponse(result.redirectUri,{error:result.error},result.state,issuer);return options.json?Response.json({redirect_uri:redirect.headers.get("Location")},{headers:AUTHORIZATION_REDIRECT_HEADERS}):redirect}export function oauthAuthorizationRequestErrorResponse(error,issuer){if(error.redirectUri===null)return null;return redirectResponse(error.redirectUri,{error:error.code},error.state,issuer)}
@@ -15,6 +15,8 @@ export declare function updateOAuthClient(provider: ResolvedOAuthProviderConfig,
15
15
  export declare function withAuthenticatedOAuthTokenClient<T>(clientId: string, clientSecret: string | undefined, complete: (client: OAuthAuthorizationClientRegistration) => Promise<T>): Promise<T | null>;
16
16
  /** Validate and persist one owner-managed provider client. */
17
17
  export declare function registerOAuthClient(provider: ResolvedOAuthProviderConfig, ownerId: number, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
18
+ /** Provider-controlled anonymous clients cannot acquire owner-management authority. */
19
+ export declare function registerDynamicOAuthClient(provider: ResolvedOAuthProviderConfig, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
18
20
  /** Rotate one active confidential client's secret and reveal the replacement once. */
19
21
  export declare function rotateOAuthClientSecret(ownerId: number, clientId: number): Promise<string | null>;
20
22
  /**
@@ -56,7 +58,7 @@ export declare interface ValidatedOAuthClientRegistration extends OAuthClientReg
56
58
  }
57
59
  export declare interface RegisteredOAuthClient {
58
60
  id: number
59
- ownerId: number
61
+ ownerId: number | null
60
62
  name: string
61
63
  type: 'confidential' | 'public'
62
64
  tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod
@@ -50,7 +50,7 @@ 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=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(`
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.");return persistOAuthClient(provider,ownerId,input)}export async function registerDynamicOAuthClient(provider,input){input=validateOAuthClientRegistration(provider,input);if(!provider.dynamicRegistration||input.grantTypes.some((grant)=>!["authorization_code","refresh_token"].includes(grant))||!input.redirectUris.length||input.redirectUris.some((uri)=>!provider.dynamicRegistration.allowedRedirectOrigins.includes(new URL(uri).origin)))throw new OAuthClientRegistrationError("Dynamic client registration is not allowed for this client.");return persistOAuthClient(provider,null,input)}async function persistOAuthClient(provider,ownerId,input){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,
@@ -22,6 +22,7 @@ export declare interface ApproveOAuthAuthorizationRequestSessionInput {
22
22
  workspaceId?: string | null
23
23
  resolveWorkspaceId?: () => Promise<string | null>
24
24
  authorizationCodeLifetimeMs: number
25
+ approvedScopes?: readonly string[]
25
26
  }
26
27
  export declare interface OAuthAuthorizationConsentResult {
27
28
  code: string
@@ -75,6 +76,7 @@ export declare interface OAuthAuthorizationConsentView {
75
76
  export declare interface OAuthAuthorizationConsentRequest {
76
77
  requestId: string
77
78
  decision: 'approve' | 'deny'
79
+ approvedScopes?: string[]
78
80
  }
79
81
  export declare class OAuthAuthorizationConsentRequestError extends Error {
80
82
  readonly code: 'invalid_request';
@@ -1,4 +1,4 @@
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";async function assertOAuthSubjectEligibility(provider,request,subjectType,subjectId,grantId,workspaceId){if(provider.subjectEligibility&&!await provider.subjectEligibility({type:subjectType,id:subjectId,clientId:Number(request.clientId),grantId,workspaceId}))throw new OAuthAuthorizationRequestError("invalid_request","OAuth subject is not eligible.",request.redirectUri,request.state)}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(`
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";async function assertOAuthSubjectEligibility(provider,request,subjectType,subjectId,grantId,workspaceId){if(provider.subjectEligibility&&!await provider.subjectEligibility({type:subjectType,id:subjectId,clientId:Number(request.clientId),grantId,workspaceId}))throw new OAuthAuthorizationRequestError("invalid_request","OAuth subject is not eligible.",request.redirectUri,request.state)}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.");const scopeValues=params.getAll("scope");if(scopeValues.length>1)throw new OAuthAuthorizationConsentRequestError("Consent scope must appear once.");const approvedScopes=scopeValues.length?scopeValues[0].split(" "):void 0;if(approvedScopes&&(!approvedScopes.length||approvedScopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(approvedScopes).size!==approvedScopes.length))throw new OAuthAuthorizationConsentRequestError("Invalid consent scopes.");return{requestId,decision,...approvedScopes?{approvedScopes}:{}}}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
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}
@@ -9,4 +9,4 @@ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,val
9
9
  AND g.created_at >= ${sql.param(4)}
10
10
  AND ${workspacePredicate}
11
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;await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grantId,workspaceId??null);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});await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grant.id,workspaceId??null);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);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;await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grantId,workspaceId??null);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 approvedScopes=input.approvedScopes?[...input.approvedScopes]:request.scopes;if(!approvedScopes.length||new Set(approvedScopes).size!==approvedScopes.length||approvedScopes.some((scope)=>!request.scopes.includes(scope)))throw new OAuthAuthorizationRequestError("invalid_scope","Consent may only grant a non-empty subset of requested scopes.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:approvedScopes,resources:request.resources,audiences:request.audiences,workspaceId});await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grant.id,workspaceId??null);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,5 @@
1
+ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
2
+ /** RFC 7591 metadata for an explicitly enabled authorization-code provider. No remote URLs are fetched. */
3
+ export declare function parseDynamicOAuthRegistration(provider: ResolvedOAuthProviderConfig, body: unknown): unknown;
4
+ /** The route must apply a bounded JSON read and an IP quota before calling this. */
5
+ export declare function handleDynamicOAuthRegistration(provider: ResolvedOAuthProviderConfig, body: unknown): Promise<Response>;
@@ -0,0 +1 @@
1
+ import{OAuthClientRegistrationError,registerDynamicOAuthClient,validateOAuthClientRegistration}from"./oauth-client-registration";export function parseDynamicOAuthRegistration(provider,body){if(!provider.dynamicRegistration||!body||typeof body!=="object"||Array.isArray(body))throw new OAuthClientRegistrationError("Dynamic registration is unavailable or the metadata is invalid.");const input=body,strings=(value,fallback)=>{if(value===void 0)return fallback;if(!Array.isArray(value)||value.length>10||!value.every((item)=>typeof item==="string"&&item.length<=2048))throw new OAuthClientRegistrationError("Invalid client metadata array.");return value},method=input.token_endpoint_auth_method??"client_secret_basic";if(method!=="none"&&method!=="client_secret_basic")throw new OAuthClientRegistrationError("Unsupported token endpoint authentication method.");const redirectUris=strings(input.redirect_uris,[]);if(!redirectUris.length||redirectUris.some((uri)=>{try{return!provider.dynamicRegistration.allowedRedirectOrigins.includes(new URL(uri).origin)}catch{return!0}}))throw new OAuthClientRegistrationError("Redirect origin is not allowed.");const responseTypes=strings(input.response_types,["code"]),grantTypes=strings(input.grant_types,["authorization_code"]);if(responseTypes.length!==1||responseTypes[0]!=="code"||grantTypes.some((grant)=>!["authorization_code","refresh_token"].includes(grant)))throw new OAuthClientRegistrationError("Only the authorization-code profile is supported.");if(input.scope!==void 0&&(typeof input.scope!=="string"||input.scope.length>4096))throw new OAuthClientRegistrationError("Invalid scope metadata.");if(input.client_name!==void 0&&typeof input.client_name!=="string")throw new OAuthClientRegistrationError("Invalid client name.");const scopes=typeof input.scope==="string"?input.scope.split(" "):Object.keys(provider.scopes);return validateOAuthClientRegistration(provider,{name:typeof input.client_name==="string"?input.client_name:"MCP client",type:method==="none"?"public":"confidential",tokenEndpointAuthMethod:method,redirectUris,grantTypes,scopes,resources:Object.keys(provider.resources)})}export async function handleDynamicOAuthRegistration(provider,body){const headers={"Content-Type":"application/json; charset=utf-8","Cache-Control":"no-store",Pragma:"no-cache"};if(!provider.dynamicRegistration)return new Response(null,{status:404,headers});try{const input=parseDynamicOAuthRegistration(provider,body),result=await registerDynamicOAuthClient(provider,input);return Response.json({client_id:String(result.client.id),client_id_issued_at:Math.floor(result.client.createdAt.getTime()/1000),client_name:result.client.name,redirect_uris:result.client.redirectUris,token_endpoint_auth_method:result.client.tokenEndpointAuthMethod,grant_types:result.client.grantTypes,response_types:["code"],scope:result.client.scopes.join(" "),...result.plainTextSecret?{client_secret:result.plainTextSecret,client_secret_expires_at:0}:{}},{status:201,headers})}catch(error){if(!(error instanceof OAuthClientRegistrationError))throw error;return Response.json({error:"invalid_client_metadata",error_description:error.message},{status:400,headers})}}
@@ -4,6 +4,8 @@ export declare function oauthAuthorizationServerMetadataPath(issuer: string): st
4
4
  /** Build RFC 8414 metadata from the provider profile Stacks actually serves. */
5
5
  export declare function oauthAuthorizationServerMetadata(provider: ResolvedOAuthProviderConfig): OAuthAuthorizationServerMetadata;
6
6
  export declare interface OAuthAuthorizationServerMetadata {
7
+ registration_endpoint?: string
8
+ authorization_response_iss_parameter_supported: true
7
9
  issuer: string
8
10
  authorization_endpoint: string
9
11
  token_endpoint: string
@@ -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 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),metadata={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()};if(provider.introspection)metadata.introspection_endpoint=provider.endpoints.introspection;return metadata}
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),metadata={issuer:provider.issuer,authorization_response_iss_parameter_supported:!0,...provider.dynamicRegistration?{registration_endpoint:provider.dynamicRegistration.endpoint}:{},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()};if(provider.introspection)metadata.introspection_endpoint=provider.endpoints.introspection;return metadata}
@@ -0,0 +1,13 @@
1
+ /** RFC 9728 metadata for the exact protected audience, never the inbound Host. */
2
+ export declare function oauthProtectedResourceMetadata(options: OAuthProtectedResourceOptions): {
3
+ resource: OAuthProtectedResourceOptions.resource;
4
+ authorization_servers: (OAuthProtectedResourceOptions.authorizationServers)[number][];
5
+ bearer_methods_supported: string[]
6
+ };
7
+ export declare function oauthProtectedResourceMetadataPath(resource: string): string;
8
+ export declare interface OAuthProtectedResourceOptions {
9
+ resource: string
10
+ authorizationServers: readonly string[]
11
+ scopesSupported?: readonly string[]
12
+ documentation?: string
13
+ }
@@ -0,0 +1 @@
1
+ export function oauthProtectedResourceMetadata(options){for(const value of[options.resource,...options.authorizationServers,...options.documentation?[options.documentation]:[]]){const url=new URL(value),loopback=["localhost","127.0.0.1","[::1]"].includes(url.hostname);if(url.protocol!=="https:"&&!(loopback&&url.protocol==="http:")||url.username||url.password||url.hash||url.search)throw TypeError("OAuth resource metadata requires canonical HTTPS URLs, or HTTP loopback URLs.")}if(!options.authorizationServers.length)throw TypeError("An OAuth resource needs an authorization server.");return{resource:options.resource,authorization_servers:[...options.authorizationServers],bearer_methods_supported:["header"],...options.scopesSupported?{scopes_supported:[...options.scopesSupported]}:{},...options.documentation?{resource_documentation:options.documentation}:{}}}export function oauthProtectedResourceMetadataPath(resource){return`/.well-known/oauth-protected-resource${new URL(resource).pathname.replace(/\/$/,"")}`}
@@ -1,4 +1,5 @@
1
1
  import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig, OAuthProviderSubjectEligibility } from '@stacksjs/types';
2
+ declare function endpoint(issuer: URL, configured: string | undefined, fallback: string): string;
2
3
  /** Resolve and validate application-owned workspace authority for consent. */
3
4
  export declare function resolveOAuthConsentWorkspace(provider: ResolvedOAuthProviderConfig, context: OAuthProviderConsentWorkspaceContext): Promise<OAuthProviderConsentWorkspace | null>;
4
5
  /**
@@ -20,6 +21,7 @@ export declare interface ResolvedOAuthProviderConfig {
20
21
  revocation: string
21
22
  introspection: string
22
23
  }
24
+ dynamicRegistration?: { endpoint: string, allowedRedirectOrigins: readonly string[] }
23
25
  responseTypes: readonly ['code']
24
26
  grantTypes: readonly ('authorization_code' | 'refresh_token' | 'client_credentials')[]
25
27
  clientCredentials: boolean
@@ -1 +1 @@
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",introspection:"oauth/introspect"},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),subjectEligibility=options.subjectEligibility;if(subjectEligibility!==void 0&&typeof subjectEligibility!=="function")throw Error("auth.oauthProvider.subjectEligibility must be a function.");const 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),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)};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"]);assertEndpointRouteAvailable("introspection",endpoints.introspection,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");const grantTypes=options.clientCredentials?["authorization_code","refresh_token","client_credentials"]:["authorization_code","refresh_token"];return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,responseTypes:["code"],grantTypes,clientCredentials:options.clientCredentials===!0,introspection:options.introspection===!0,...subjectEligibility?{subjectEligibility}:{},codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
1
+ import{oauthAuthorizationServerMetadataPath}from"./oauth-metadata";const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"},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),subjectEligibility=options.subjectEligibility;if(subjectEligibility!==void 0&&typeof subjectEligibility!=="function")throw Error("auth.oauthProvider.subjectEligibility must be a function.");const 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),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)};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"]);assertEndpointRouteAvailable("introspection",endpoints.introspection,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");let dynamicRegistration;if(options.dynamicRegistration){const origins=[...options.dynamicRegistration.allowedRedirectOrigins];if(!origins.length||origins.some((origin)=>new URL(origin).origin!==origin||!origin.startsWith("https://")))throw Error("OAuth dynamic registration requires exact HTTPS redirect origins.");const registration=endpoint(issuer,options.dynamicRegistration.endpoint,"oauth/register");if(Object.values(endpoints).includes(registration)||new URL(registration).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("OAuth registration endpoint must use a unique URL.");assertEndpointRouteAvailable("registration",registration,["POST"]);if(!resolvedClientTypes(options.clientTypes).includes("public"))throw Error("OAuth dynamic registration requires public clients.");dynamicRegistration={endpoint:registration,allowedRedirectOrigins:origins}}const grantTypes=options.clientCredentials?["authorization_code","refresh_token","client_credentials"]:["authorization_code","refresh_token"];return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,...dynamicRegistration?{dynamicRegistration}:{},responseTypes:["code"],grantTypes,clientCredentials:options.clientCredentials===!0,introspection:options.introspection===!0,...subjectEligibility?{subjectEligibility}:{},codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
@@ -0,0 +1,24 @@
1
+ import type { OAuthAuthorizationPageSubject } from './oauth-authorization-endpoint';
2
+ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
3
+ /**
4
+ * Framework-owned OAuth HTTP boundaries for a custom application consent UI.
5
+ * Mount raw-body protocol handlers with parseBody(false) and protocol CSRF exemptions.
6
+ * Consent and connection handlers require ordinary login and router CSRF protection.
7
+ * The application supplies its domain eligibility policy and authenticated subject.
8
+ */
9
+ export declare function createOAuthAuthorizationServer(options: OAuthAuthorizationServerOptions): {
10
+ metadata: () => unknown;
11
+ register: (request: Request) => Promise<Response>;
12
+ begin: (request: Request) => Promise<Response>;
13
+ consent: (request: Request) => Promise<Response>;
14
+ token: (request: Request) => any;
15
+ revoke: (request: Request) => any;
16
+ connections: (request: Request) => Promise<Response>;
17
+ disconnect: (request: Request, grantId: string) => Promise<Response>
18
+ };
19
+ export declare interface OAuthAuthorizationServerOptions {
20
+ provider: ResolvedOAuthProviderConfig | (() => ResolvedOAuthProviderConfig)
21
+ consentPage: string
22
+ authenticate: (request: Request) => Promise<(OAuthAuthorizationPageSubject & { label: string }) | Response | null>
23
+ connectionAudience?: string
24
+ }
@@ -0,0 +1 @@
1
+ import{readJsonBody,readTextBody,RequestBodyError}from"@stacksjs/router/body";import{OAuthAuthorizationRequestError}from"./oauth-authorization";import{beginOAuthAuthorizationRequest,handleOAuthAuthorizationConsentRequest}from"./oauth-authorization-endpoint";import{bindOAuthAuthorizationRequestWorkspace}from"./oauth-authorization-requests";import{oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{loadOAuthAuthorizationConsentView}from"./oauth-consent";import{handleDynamicOAuthRegistration}from"./oauth-dynamic-registration";import{disconnectOAuthGrant,listOAuthConnections}from"./oauth-grants";import{oauthAuthorizationServerMetadata}from"./oauth-metadata";import{handleOAuthRevocationRequest}from"./oauth-revocation";import{handleOAuthTokenRequest}from"./oauth-token-endpoint";function json(body,status=200){return Response.json(body,{status,headers:{"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"}})}const invalid=()=>json({error:"invalid_request"},400);export function createOAuthAuthorizationServer(options){if(!/^\/(?!\/)[^?#\\]*$/.test(options.consentPage)||/[\u0000-\u0020]/.test(options.consentPage))throw TypeError("OAuth consentPage must be a local absolute path.");const provider=()=>typeof options.provider==="function"?options.provider():options.provider,identity=async(request)=>{const subject=await options.authenticate(request);if(subject instanceof Response)return subject;if(!subject||!/^[A-Za-z0-9_.:-]{1,255}$/.test(subject.type)||!Number.isSafeInteger(subject.id)||subject.id<=0||!subject.label.trim())return json({error:"authentication_required"},401);return subject},method=(request,expected)=>request.method!==expected?new Response(null,{status:405,headers:{Allow:expected,"Cache-Control":"no-store"}}):null,wire=async(request,handler)=>{const denied=method(request,"POST");if(denied)return denied;try{return await handler(await readTextBody(request,16384))}catch(error){if(error instanceof RequestBodyError)return json({error:"invalid_request"},error.status);throw error}};return{metadata:()=>json(oauthAuthorizationServerMetadata(provider())),async register(request){const denied=method(request,"POST");if(denied)return denied;try{return await handleDynamicOAuthRegistration(provider(),await readJsonBody(request,16384))}catch(error){if(error instanceof RequestBodyError)return json({error:"invalid_client_metadata"},error.status);throw error}},async begin(request){const denied=method(request,"GET");if(denied)return denied;const resolved=provider(),browser=oauthAuthorizationBrowserSession(request,resolved);try{const result=await beginOAuthAuthorizationRequest({provider:resolved,query:new URL(request.url).searchParams,browserSessionId:browser.id});return new Response(null,{status:302,headers:{Location:`${options.consentPage}?request_id=${encodeURIComponent(result.requestId)}`,"Set-Cookie":browser.cookie,"Cache-Control":"no-store","Referrer-Policy":"no-referrer"}})}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;return oauthAuthorizationRequestErrorResponse(error,resolved.issuer)??invalid()}},async consent(request){if(!["GET","POST"].includes(request.method))return method(request,"GET, POST");const subject=await identity(request);if(subject instanceof Response)return subject;const resolved=provider();if(request.method==="POST")return handleOAuthAuthorizationConsentRequest({provider:resolved,request,subjectType:subject.type,subjectId:subject.id,workspaceId:subject.workspaceId,resolveWorkspaceId:subject.resolveWorkspaceId});const params=new URL(request.url).searchParams;if(params.getAll("request_id").length!==1||[...params.keys()].some((key)=>key!=="request_id"))return invalid();if(resolved.consent.resolveWorkspace&&!await bindOAuthAuthorizationRequestWorkspace(params.get("request_id"),oauthAuthorizationBrowserSession(request,resolved).id,subject.workspaceId??null))return invalid();const consent=await loadOAuthAuthorizationConsentView({provider:resolved,requestId:params.get("request_id"),browserSessionId:oauthAuthorizationBrowserSession(request,resolved).id});if(!consent)return invalid();return json({consent,user:{id:subject.id,name:subject.label}})},token:(request)=>wire(request,(body)=>{const resolved=provider();return handleOAuthTokenRequest(resolved,{body,contentType:request.headers.get("content-type"),authorization:request.headers.get("authorization")},{isSubjectEligible:resolved.subjectEligibility??(()=>!1)})}),revoke:(request)=>wire(request,(body)=>handleOAuthRevocationRequest({body,contentType:request.headers.get("content-type"),authorization:request.headers.get("authorization")})),async connections(request){const denied=method(request,"GET");if(denied)return denied;const subject=await identity(request);if(subject instanceof Response)return subject;const connections=(await listOAuthConnections(subject.type,subject.id)).filter((connection)=>!options.connectionAudience||connection.audiences.includes(options.connectionAudience));return json({connections})},async disconnect(request,grantId){const denied=method(request,"DELETE");if(denied)return denied;const subject=await identity(request);if(subject instanceof Response)return subject;if(!(await listOAuthConnections(subject.type,subject.id)).some((connection)=>connection.grantId===grantId&&(!options.connectionAudience||connection.audiences.includes(options.connectionAudience))))return json({disconnected:!1});return json({disconnected:await disconnectOAuthGrant(subject.type,subject.id,grantId)})}}}
@@ -1 +1 @@
1
- import{exchangeOAuthClientCredentials,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)}if(request.grantType==="client_credentials"){const result=await exchangeOAuthClientCredentials({provider,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken});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
+ import{exchangeOAuthClientCredentials,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,audiences:request.audiences,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)}if(request.grantType==="client_credentials"){const result=await exchangeOAuthClientCredentials({provider,audiences:request.audiences,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,audiences:request.audiences,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}}
@@ -33,6 +33,7 @@ export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAut
33
33
  }
34
34
  export declare interface RefreshOAuthDelegatedTokenInput {
35
35
  refreshToken: string
36
+ audiences?: readonly string[]
36
37
  clientId: number
37
38
  clientSecret?: string
38
39
  scopes?: readonly string[]
@@ -41,6 +42,7 @@ export declare interface RefreshOAuthDelegatedTokenInput {
41
42
  isSubjectEligible?: OAuthSubjectEligibility
42
43
  }
43
44
  export declare interface ExchangeOAuthClientCredentialsInput {
45
+ audiences?: readonly string[]
44
46
  provider: ResolvedOAuthProviderConfig
45
47
  clientId: number
46
48
  clientSecret?: string
@@ -33,7 +33,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
33
33
  SELECT id FROM oauth_clients
34
34
  WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
35
35
  LIMIT 1${clientLock}
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 exchangeOAuthClientCredentials(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!input.provider.clientCredentials||client.type!=="confidential"||!client.grantTypes.includes("client_credentials"))return{result:unauthorizedClient,wrote:!1};const scopes=input.scopes?[...input.scopes]:[...client.scopes];if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>client.scopes.includes(scope)&&input.provider.scopes[scope]))return{result:invalidScope,wrote:!1};const resources=[...new Set(scopes.flatMap((scope)=>input.provider.scopes[scope]?.resources??[]))];if(!resources.every((resource)=>client.resources.includes(resource)&&input.provider.resources[resource]))return{result:invalidScope,wrote:!1};const machineClientId=Number(client.id),grant=await createOAuthGrant({clientId:machineClientId,subjectType:"oauth_clients",subjectId:machineClientId,scopes,resources,audiences:resources.map((resource)=>input.provider.resources[resource].audience)});return{result:{ok:!0,value:await mintDelegatedTokenPair({grantId:grant.id,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId},{accessLifetimeMs,refreshLifetimeMs:0,issueRefreshToken:!1})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}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 exchangeOAuthClientCredentials(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!input.provider.clientCredentials||client.type!=="confidential"||!client.grantTypes.includes("client_credentials"))return{result:unauthorizedClient,wrote:!1};const scopes=input.scopes?[...input.scopes]:[...client.scopes];if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>client.scopes.includes(scope)&&input.provider.scopes[scope]))return{result:invalidScope,wrote:!1};const resources=[...new Set(scopes.flatMap((scope)=>input.provider.scopes[scope]?.resources??[]))];if(!resources.every((resource)=>client.resources.includes(resource)&&input.provider.resources[resource]))return{result:invalidScope,wrote:!1};const machineClientId=Number(client.id);if(input.audiences&&(!input.audiences.length||new Set(input.audiences).size!==input.audiences.length||input.audiences.length!==resources.length||input.audiences.some((audience)=>!resources.some((resource)=>input.provider.resources[resource]?.audience===audience))))return{result:invalidScope,wrote:!1};const grant=await createOAuthGrant({clientId:machineClientId,subjectType:"oauth_clients",subjectId:machineClientId,scopes,resources,audiences:resources.map((resource)=>input.provider.resources[resource].audience)});return{result:{ok:!0,value:await mintDelegatedTokenPair({grantId:grant.id,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId},{accessLifetimeMs,refreshLifetimeMs:0,issueRefreshToken:!1})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}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(`
37
37
  SELECT r.id, r.family_id, a.oauth_client_id
38
38
  FROM oauth_refresh_tokens r
39
39
  JOIN oauth_access_tokens a ON a.id = r.access_token_id
@@ -60,7 +60,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
60
60
  FROM oauth_grants
61
61
  WHERE id = ${sql.param(1)}
62
62
  LIMIT 1${grantLock}
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(`
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}}if(input.audiences&&(!input.audiences.length||new Set(input.audiences).size!==input.audiences.length||input.audiences.length!==grant.audiences.length||input.audiences.some((audience)=>!grant.audiences.includes(audience))))return{result:invalidGrant,wrote:!1};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(`
64
64
  UPDATE oauth_refresh_tokens
65
65
  SET revoked = ${sql.param(1)}
66
66
  WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
@@ -5,11 +5,13 @@ export declare function parseOAuthRevocationRequest(body: string, authorization?
5
5
  /** Parse one RFC 7662 introspection request using confidential Basic authentication. */
6
6
  export declare function parseOAuthIntrospectionRequest(body: string, authorization?: string | null): OAuthIntrospectionRequest;
7
7
  export declare interface OAuthClientCredentials {
8
+ audiences?: string[]
8
9
  clientId: number
9
10
  clientSecret?: string
10
11
  }
11
12
  export declare interface OAuthAuthorizationCodeTokenRequest {
12
13
  grantType: 'authorization_code'
14
+ audiences?: string[]
13
15
  clientId: number
14
16
  clientSecret?: string
15
17
  code: string
@@ -18,6 +20,7 @@ export declare interface OAuthAuthorizationCodeTokenRequest {
18
20
  }
19
21
  export declare interface OAuthRefreshTokenRequest {
20
22
  grantType: 'refresh_token'
23
+ audiences?: string[]
21
24
  clientId: number
22
25
  clientSecret?: string
23
26
  refreshToken: string
@@ -1 +1 @@
1
- import{Buffer}from"node:buffer";export class OAuthTokenRequestError extends Error{error;constructor(error,message){super(message);this.error=error;this.name="OAuthTokenRequestError"}}function reject(error,message){throw new OAuthTokenRequestError(error,message)}function single(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth token parameter must not be repeated: ${name}`);const value=values[0];if(required&&!value)reject("invalid_request",`OAuth token parameter is required: ${name}`);if(value?.includes("\uFFFD"))reject("invalid_request",`OAuth token parameter is not valid UTF-8: ${name}`);return value}function clientIdentifier(value){if(!/^[0-9]{1,20}$/.test(value))reject("invalid_client","OAuth client identifier is invalid.");const id=Number(value);if(!Number.isSafeInteger(id)||id<=0)reject("invalid_client","OAuth client identifier is invalid.");return id}function decodeCredential(value){try{return decodeURIComponent(value.replace(/\+/g," "))}catch{return reject("invalid_client","OAuth client credentials are malformed.")}}function basicCredentials(header){const match=/^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);if(!match)reject("invalid_client","OAuth client authentication must use a valid Basic header.");const encoded=match[1];let bytes;try{bytes=Buffer.from(encoded,"base64")}catch{return reject("invalid_client","OAuth client credentials are malformed.")}if(bytes.toString("base64").replace(/=+$/,"")!==encoded.replace(/=+$/,""))reject("invalid_client","OAuth client credentials are malformed.");let decoded;try{decoded=new TextDecoder("utf-8",{fatal:!0}).decode(bytes)}catch{return reject("invalid_client","OAuth client credentials are not valid UTF-8.")}const separator=decoded.indexOf(":");if(separator<1)reject("invalid_client","OAuth client credentials are malformed.");const clientId=clientIdentifier(decodeCredential(decoded.slice(0,separator))),clientSecret=decodeCredential(decoded.slice(separator+1));if(!clientSecret||clientSecret.length>4096)reject("invalid_client","OAuth client secret is invalid.");return{clientId,clientSecret}}function clientCredentials(params,authorization){const bodyClientId=single(params,"client_id"),bodyClientSecret=single(params,"client_secret");if(authorization){if(bodyClientId!==void 0||bodyClientSecret!==void 0)reject("invalid_request","OAuth client authentication methods must not be combined.");return basicCredentials(authorization)}if(bodyClientSecret!==void 0)reject("invalid_request","OAuth client secrets must use Basic authentication.");return{clientId:clientIdentifier(bodyClientId??"")}}function tokenValue(params,name){const value=single(params,name,!0);if(value.length>4096||/\s/.test(value))reject("invalid_request",`OAuth token parameter is invalid: ${name}`);return value}function requestedScopes(params){const value=single(params,"scope");if(value===void 0)return;const scopes=value.split(" ");if(scopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(scopes).size!==scopes.length)reject("invalid_request","OAuth scope must contain unique scope tokens.");return scopes}export function parseOAuthTokenRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth token request is too large.");const params=new URLSearchParams(body);for(const name of["grant_type","client_id","client_secret","code","redirect_uri","code_verifier","refresh_token","scope"])single(params,name);const credentials=clientCredentials(params,authorization),grantType=single(params,"grant_type",!0);if(grantType==="authorization_code"){const codeVerifier=single(params,"code_verifier",!0);if(!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeVerifier))reject("invalid_request","OAuth PKCE verifier is invalid.");const redirectUri=single(params,"redirect_uri",!0);if(redirectUri.length>2048)reject("invalid_request","OAuth redirect URI is too large.");return{grantType,...credentials,code:tokenValue(params,"code"),redirectUri,codeVerifier}}if(grantType==="refresh_token"){const scopes=requestedScopes(params);return{grantType,...credentials,refreshToken:tokenValue(params,"refresh_token"),...scopes?{scopes}:{}}}if(grantType==="client_credentials"){if(!authorization||!credentials.clientSecret)reject("invalid_client","The client_credentials grant requires Basic client authentication.");const scopes=requestedScopes(params);return{grantType,...credentials,...scopes?{scopes}:{}}}return reject("unsupported_grant_type","OAuth grant type is not supported.")}export function parseOAuthRevocationRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth revocation request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth revocation token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}export function parseOAuthIntrospectionRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth introspection request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);if(!authorization)reject("invalid_client","OAuth introspection requires Basic client authentication.");const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth introspection token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}
1
+ import{Buffer}from"node:buffer";export class OAuthTokenRequestError extends Error{error;constructor(error,message){super(message);this.error=error;this.name="OAuthTokenRequestError"}}function reject(error,message){throw new OAuthTokenRequestError(error,message)}function single(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth token parameter must not be repeated: ${name}`);const value=values[0];if(required&&!value)reject("invalid_request",`OAuth token parameter is required: ${name}`);if(value?.includes("\uFFFD"))reject("invalid_request",`OAuth token parameter is not valid UTF-8: ${name}`);return value}function clientIdentifier(value){if(!/^[0-9]{1,20}$/.test(value))reject("invalid_client","OAuth client identifier is invalid.");const id=Number(value);if(!Number.isSafeInteger(id)||id<=0)reject("invalid_client","OAuth client identifier is invalid.");return id}function decodeCredential(value){try{return decodeURIComponent(value.replace(/\+/g," "))}catch{return reject("invalid_client","OAuth client credentials are malformed.")}}function basicCredentials(header){const match=/^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);if(!match)reject("invalid_client","OAuth client authentication must use a valid Basic header.");const encoded=match[1];let bytes;try{bytes=Buffer.from(encoded,"base64")}catch{return reject("invalid_client","OAuth client credentials are malformed.")}if(bytes.toString("base64").replace(/=+$/,"")!==encoded.replace(/=+$/,""))reject("invalid_client","OAuth client credentials are malformed.");let decoded;try{decoded=new TextDecoder("utf-8",{fatal:!0}).decode(bytes)}catch{return reject("invalid_client","OAuth client credentials are not valid UTF-8.")}const separator=decoded.indexOf(":");if(separator<1)reject("invalid_client","OAuth client credentials are malformed.");const clientId=clientIdentifier(decodeCredential(decoded.slice(0,separator))),clientSecret=decodeCredential(decoded.slice(separator+1));if(!clientSecret||clientSecret.length>4096)reject("invalid_client","OAuth client secret is invalid.");return{clientId,clientSecret}}function clientCredentials(params,authorization){const bodyClientId=single(params,"client_id"),bodyClientSecret=single(params,"client_secret");if(authorization){if(bodyClientId!==void 0||bodyClientSecret!==void 0)reject("invalid_request","OAuth client authentication methods must not be combined.");return basicCredentials(authorization)}if(bodyClientSecret!==void 0)reject("invalid_request","OAuth client secrets must use Basic authentication.");return{clientId:clientIdentifier(bodyClientId??"")}}function tokenValue(params,name){const value=single(params,name,!0);if(value.length>4096||/\s/.test(value))reject("invalid_request",`OAuth token parameter is invalid: ${name}`);return value}function requestedScopes(params){const value=single(params,"scope");if(value===void 0)return;const scopes=value.split(" ");if(scopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(scopes).size!==scopes.length)reject("invalid_request","OAuth scope must contain unique scope tokens.");return scopes}export function parseOAuthTokenRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth token request is too large.");const params=new URLSearchParams(body);for(const name of["grant_type","client_id","client_secret","code","redirect_uri","code_verifier","refresh_token","scope"])single(params,name);const credentials=clientCredentials(params,authorization),audiences=params.getAll("resource");if(audiences.length>10||new Set(audiences).size!==audiences.length||audiences.some((value)=>{try{const url=new URL(value);return!!(url.username||url.password||url.hash)}catch{return!0}}))reject("invalid_request","OAuth resource must contain unique absolute URIs without credentials or fragments.");if(audiences.length)credentials.audiences=audiences;const grantType=single(params,"grant_type",!0);if(grantType==="authorization_code"){const codeVerifier=single(params,"code_verifier",!0);if(!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeVerifier))reject("invalid_request","OAuth PKCE verifier is invalid.");const redirectUri=single(params,"redirect_uri",!0);if(redirectUri.length>2048)reject("invalid_request","OAuth redirect URI is too large.");return{grantType,...credentials,code:tokenValue(params,"code"),redirectUri,codeVerifier}}if(grantType==="refresh_token"){const scopes=requestedScopes(params);return{grantType,...credentials,refreshToken:tokenValue(params,"refresh_token"),...scopes?{scopes}:{}}}if(grantType==="client_credentials"){if(!authorization||!credentials.clientSecret)reject("invalid_client","The client_credentials grant requires Basic client authentication.");const scopes=requestedScopes(params);return{grantType,...credentials,...scopes?{scopes}:{}}}return reject("unsupported_grant_type","OAuth grant type is not supported.")}export function parseOAuthRevocationRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth revocation request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth revocation token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}export function parseOAuthIntrospectionRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth introspection request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);if(!authorization)reject("invalid_client","OAuth introspection requires Basic client authentication.");const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth introspection token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}
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.191",
5
+ "version": "0.75.192",
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.24",
61
- "@stacksjs/cache": "0.75.191",
62
- "@stacksjs/config": "0.75.191",
63
- "@stacksjs/database": "0.75.191",
64
- "@stacksjs/email": "0.75.191",
65
- "@stacksjs/env": "0.75.191",
66
- "@stacksjs/error-handling": "0.75.191",
67
- "@stacksjs/logging": "0.75.191",
68
- "@stacksjs/orm": "0.75.191",
69
- "@stacksjs/path": "0.75.191",
70
- "@stacksjs/router": "0.75.191",
71
- "@stacksjs/security": "0.75.191",
72
- "@stacksjs/storage": "0.75.191",
61
+ "@stacksjs/cache": "0.75.192",
62
+ "@stacksjs/config": "0.75.192",
63
+ "@stacksjs/database": "0.75.192",
64
+ "@stacksjs/email": "0.75.192",
65
+ "@stacksjs/env": "0.75.192",
66
+ "@stacksjs/error-handling": "0.75.192",
67
+ "@stacksjs/logging": "0.75.192",
68
+ "@stacksjs/orm": "0.75.192",
69
+ "@stacksjs/path": "0.75.192",
70
+ "@stacksjs/router": "0.75.192",
71
+ "@stacksjs/security": "0.75.192",
72
+ "@stacksjs/storage": "0.75.192",
73
73
  "@stacksjs/ts-auth": "^0.4.5",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },