@stacksjs/auth 0.75.33 → 0.75.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-codes.js +2 -2
- package/dist/oauth-client-policy.js +1 -1
- package/dist/oauth-client-registration.js +4 -4
- package/dist/oauth-consent.js +2 -2
- package/dist/oauth-delegated-access.d.ts +2 -8
- package/dist/oauth-introspection.d.ts +31 -0
- package/dist/oauth-introspection.js +26 -0
- package/dist/oauth-metadata.d.ts +1 -0
- package/dist/oauth-metadata.js +1 -1
- package/dist/oauth-provider.d.ts +6 -2
- package/dist/oauth-provider.js +1 -1
- package/dist/oauth-pruning.js +1 -1
- package/dist/oauth-token-endpoint.js +1 -1
- package/dist/oauth-token-exchange.d.ts +16 -2
- package/dist/oauth-token-exchange.js +2 -2
- package/dist/oauth-token-request.d.ts +11 -1
- package/dist/oauth-token-request.js +1 -1
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -50,8 +50,9 @@ oauthProvider: {
|
|
|
50
50
|
The default auth route bundle then serves:
|
|
51
51
|
|
|
52
52
|
- `GET` and `POST /oauth/authorize` for login, consent, denial, and S256 PKCE
|
|
53
|
-
- `POST /oauth/token` for authorization-code exchange
|
|
53
|
+
- `POST /oauth/token` for authorization-code exchange, rotating refresh tokens, and opt-in confidential client-credentials tokens
|
|
54
54
|
- `POST /oauth/revoke` for client-owned access and refresh token revocation
|
|
55
|
+
- `POST /oauth/introspect` when `oauthProvider.introspection` is enabled
|
|
55
56
|
- `GET /.well-known/oauth-authorization-server` for RFC 8414 metadata
|
|
56
57
|
- authenticated client registration, editing, secret rotation, and disable routes under `/auth/oauth/clients`
|
|
57
58
|
- authenticated connected-application listing and disconnect routes under `/auth/oauth/connections`
|
|
@@ -61,9 +62,13 @@ tokens, browser authorization state, and confidential client secrets. Client
|
|
|
61
62
|
redirects use exact matching. Consent and issued credentials remain bound to
|
|
62
63
|
the client, subject, scopes, resources, audiences, and optional workspace.
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
Client credentials are disabled by default. When enabled with
|
|
66
|
+
`oauthProvider.clientCredentials`, only confidential clients may use the grant,
|
|
67
|
+
and it mints access-only tokens with scopes and resource audiences derived from
|
|
68
|
+
the registered client policy. Protected resource-server introspection is
|
|
69
|
+
separately opt-in through `oauthProvider.introspection`; it authenticates a
|
|
70
|
+
confidential client and checks its resource audience against the token.
|
|
71
|
+
OpenID Connect is not implemented.
|
|
67
72
|
|
|
68
73
|
## 🧪 Testing
|
|
69
74
|
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from './oauth-client-registration';
|
|
|
14
14
|
export * from './oauth-consent';
|
|
15
15
|
export * from './oauth-delegated-access';
|
|
16
16
|
export * from './oauth-grants';
|
|
17
|
+
export * from './oauth-introspection';
|
|
17
18
|
export * from './oauth-metadata';
|
|
18
19
|
export * from './oauth-provider';
|
|
19
20
|
export * from './oauth-pruning';
|
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-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
1
|
+
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"./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,9 +1,9 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isSafeInteger(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer of milliseconds.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isSafeInteger(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer of milliseconds.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+input.lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization code lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
|
|
2
2
|
SELECT id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id
|
|
3
3
|
FROM oauth_grants
|
|
4
4
|
WHERE id = ${sql.param(1)} AND revoked_at IS NULL
|
|
5
5
|
LIMIT 1${grantLock}
|
|
6
|
-
`,[input.grantId]))[0];if(!grant)throw Error("OAuth authorization grant is not active.");const bound=sql.params(codeHash(plainTextCode),input.grantId,grant.client_id,grant.subject_type,grant.subject_id,input.redirectUri,grant.scopes,grant.resources,grant.audiences,grant.workspace_id??null,input.codeChallenge,"S256",sqlDateTime(
|
|
6
|
+
`,[input.grantId]))[0];if(!grant)throw Error("OAuth authorization grant is not active.");const bound=sql.params(codeHash(plainTextCode),input.grantId,grant.client_id,grant.subject_type,grant.subject_id,input.redirectUri,grant.scopes,grant.resources,grant.audiences,grant.workspace_id??null,input.codeChallenge,"S256",sqlDateTime(expiresAt),sqlDateTime(createdAt));await trx.unsafe(`
|
|
7
7
|
INSERT INTO oauth_auth_codes (
|
|
8
8
|
code_hash, grant_id, client_id, subject_type, subject_id, redirect_uri, scopes, resources,
|
|
9
9
|
audiences, workspace_id, code_challenge, code_challenge_method, expires_at, created_at
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isValidOAuthRedirectUri}from"./oauth-authorization";export function storedOAuthValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export function isInactiveOAuthFlag(value){return value===!1||value===0}function storedOAuthFlag(value){if(isInactiveOAuthFlag(value))return!1;if(value===!0||value===1)return!0;return null}export function oauthAuthorizationClientFromStored(row,id){const redirectUris=storedOAuthValues(row?.redirect_uris??null),grantTypes=storedOAuthValues(row?.grant_types??null),scopes=storedOAuthValues(row?.allowed_scopes??null),resources=storedOAuthValues(row?.allowed_resources??null)
|
|
1
|
+
import{isValidOAuthRedirectUri}from"./oauth-authorization";export function storedOAuthValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export function isInactiveOAuthFlag(value){return value===!1||value===0}function storedOAuthFlag(value){if(isInactiveOAuthFlag(value))return!1;if(value===!0||value===1)return!0;return null}export function oauthAuthorizationClientFromStored(row,id){const redirectUris=storedOAuthValues(row?.redirect_uris??null),grantTypes=storedOAuthValues(row?.grant_types??null),scopes=storedOAuthValues(row?.allowed_scopes??null),resources=storedOAuthValues(row?.allowed_resources??null);if(!row||!redirectUris||!grantTypes||!scopes||!resources)return null;const revoked=row?storedOAuthFlag(row.revoked):null,type=row?.client_type,method=row?.token_endpoint_auth_method,authorizationCode=grantTypes.includes("authorization_code"),clientCredentials=grantTypes.includes("client_credentials");if(String(row.id)!==String(id)||type!=="public"&&type!=="confidential"||method!=="none"&&method!=="client_secret_basic"||(type==="public"?method!=="none"||row.secret!=null:method!=="client_secret_basic"||!row.secret)||!authorizationCode&&!clientCredentials||(authorizationCode?!redirectUris.length:redirectUris.length>0)||clientCredentials&&type!=="confidential"||!scopes.length||new Set(redirectUris).size!==redirectUris.length||new Set(grantTypes).size!==grantTypes.length||new Set(scopes).size!==scopes.length||new Set(resources).size!==resources.length||redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri))||row.redirect!==(authorizationCode?redirectUris[0]:"")||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isInactiveOAuthFlag,oauthAuthorizationClientFromStored,storedOAuthValues}from"./oauth-client-policy";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isInactiveOAuthFlag,oauthAuthorizationClientFromStored,storedOAuthValues}from"./oauth-client-policy";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const grantTypes=explicitUnique("grant type",input.grantTypes,{required:!0}),authorizationCode=grantTypes.includes("authorization_code"),clientCredentials=grantTypes.includes("client_credentials");if(!authorizationCode&&!clientCredentials)reject("OAuth client grant types must include authorization_code or client_credentials.");if(clientCredentials&&input.type!=="confidential")reject("The client_credentials grant is available only to confidential OAuth clients.");if(grantTypes.includes("refresh_token")&&!authorizationCode)reject("OAuth refresh_token requires the authorization_code grant.");const redirectUris=explicitUnique("redirect URI",input.redirectUris,{required:authorizationCode});if(!authorizationCode&&redirectUris.length>0)reject("OAuth client credentials clients must not register redirect URIs.");if(redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri)))reject("OAuth client redirect URIs must be exact HTTPS URLs or exact loopback HTTP URLs, without credentials, fragments, or wildcards.");for(const grantType of grantTypes)if(!(provider.grantTypes.includes(grantType)||grantType==="client_credentials"&&provider.clientCredentials))reject(`OAuth client requested an unsupported grant type: ${grantType}`);const scopes=explicitUnique("scope",input.scopes,{required:!0});for(const scope of scopes)if(!provider.scopes[scope])reject(`OAuth client requested an unregistered scope: ${scope}`);const resources=explicitUnique("resource",input.resources);for(const resource of resources)if(!provider.resources[resource])reject(`OAuth client requested an unregistered resource: ${resource}`);for(const scope of scopes){const allowedResources=provider.scopes[scope]?.resources;if(allowedResources?.length&&!resources.some((resource)=>allowedResources.includes(resource)))reject(`OAuth client scope requires a registered resource: ${scope}`)}return{name,type:input.type,tokenEndpointAuthMethod:input.tokenEndpointAuthMethod,redirectUris,grantTypes,scopes,resources,requiresSecret:input.type==="confidential"}}function sameValues(left,right){return left.length===right.length&&left.every((value,index)=>value===right[index])}async function revokeOAuthClientAuthorizationState(trx,sql,clientId,now){const revoked=sql.isPostgres?!0:1;await trx.unsafe(`
|
|
2
2
|
UPDATE oauth_grants
|
|
3
3
|
SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
4
4
|
WHERE client_id = ${sql.param(3)} AND revoked_at IS NULL
|
|
@@ -45,12 +45,12 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
45
45
|
grant_types = ${sql.param(4)}, allowed_scopes = ${sql.param(5)}, allowed_resources = ${sql.param(6)},
|
|
46
46
|
updated_at = ${sql.param(7)}
|
|
47
47
|
WHERE id = ${sql.param(8)} AND user_id = ${sql.param(9)} AND revoked = ${sql.param(10)}
|
|
48
|
-
`,[validated.name,validated.redirectUris[0],JSON.stringify(validated.redirectUris),JSON.stringify(validated.grantTypes),JSON.stringify(validated.scopes),JSON.stringify(validated.resources),now,clientId,ownerId,active]);if(mutationCount(updated)!==1)return null;if(policyChanged)await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return{changed:!0,client}});if(!result)return null;if(result.changed)markContextWrote();return result.client}export async function withAuthenticatedOAuthTokenClient(clientId,clientSecret,complete){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0||clientSecret!=null&&clientSecret.length>4096)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";return db.transaction(async(rawTrx)=>{const row=(await rawTrx.unsafe(`
|
|
48
|
+
`,[validated.name,validated.redirectUris[0]??"",JSON.stringify(validated.redirectUris),JSON.stringify(validated.grantTypes),JSON.stringify(validated.scopes),JSON.stringify(validated.resources),now,clientId,ownerId,active]);if(mutationCount(updated)!==1)return null;if(policyChanged)await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return{changed:!0,client}});if(!result)return null;if(result.changed)markContextWrote();return result.client}export async function withAuthenticatedOAuthTokenClient(clientId,clientSecret,complete){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0||clientSecret!=null&&clientSecret.length>4096)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";return db.transaction(async(rawTrx)=>{const row=(await rawTrx.unsafe(`
|
|
49
49
|
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
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.");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,
|
|
@@ -61,7 +61,7 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
61
61
|
grant_types, token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
62
62
|
personal_access_client, password_client, revoked, created_at
|
|
63
63
|
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
|
|
64
|
-
`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
|
|
64
|
+
`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==(client.redirectUris[0]??"")||row.client_type!==client.type||JSON.stringify(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
|
|
65
65
|
SELECT client_type, token_endpoint_auth_method, revoked
|
|
66
66
|
FROM oauth_clients
|
|
67
67
|
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
package/dist/oauth-consent.js
CHANGED
|
@@ -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";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.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}async function reusableOAuthConsentGrantId(input,options={}){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return null;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return null;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return null;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`,lock=options.lock?sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"":"",rows=await db.primary.unsafe(`
|
|
2
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;return{code:await issueAuthorizationCode({grantId,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.provider.lifetimes.authorizationCode}),grantId,redirectUri:request.redirectUri,state:request.state}})}catch(error){if(error instanceof NoReusableOAuthConsentError)return null;throw error}}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new OAuthAuthorizationRequestError("invalid_request","OAuth consent workspace changed after it was displayed.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
|
|
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}))}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import type { AccessToken } from '@stacksjs/types';
|
|
1
|
+
import type { AccessToken, OAuthProviderSubject } from '@stacksjs/types';
|
|
2
2
|
/** Resolve and authorize a delegated bearer without promoting it to a user session. */
|
|
3
3
|
export declare function authorizeOAuthDelegatedToken(bearer: string, requirement: OAuthDelegatedAccessRequirement): Promise<OAuthDelegatedAccessResult>;
|
|
4
4
|
/** Format an RFC 6750 challenge for a rejected delegated bearer. */
|
|
5
5
|
export declare function oauthBearerAuthorizationErrorResponse(failure: OAuthDelegatedAccessFailure): Response;
|
|
6
|
-
export declare interface OAuthDelegatedSubject {
|
|
7
|
-
type: string
|
|
8
|
-
id: number
|
|
9
|
-
clientId: number
|
|
10
|
-
grantId: string
|
|
11
|
-
workspaceId: string | null
|
|
12
|
-
}
|
|
13
6
|
export declare interface OAuthDelegatedAccessRequirement {
|
|
14
7
|
scopes: readonly string[]
|
|
15
8
|
resource: string
|
|
@@ -17,6 +10,7 @@ export declare interface OAuthDelegatedAccessRequirement {
|
|
|
17
10
|
workspaceId: string | null
|
|
18
11
|
isSubjectEligible: (subject: OAuthDelegatedSubject) => boolean | Promise<boolean>
|
|
19
12
|
}
|
|
13
|
+
export type OAuthDelegatedSubject = OAuthProviderSubject;
|
|
20
14
|
export type OAuthDelegatedAccessResult = { ok: true, token: AccessToken }
|
|
21
15
|
| { ok: false, reason: 'invalid_token' }
|
|
22
16
|
| { ok: false, reason: 'insufficient_scope', requiredScopes: string[] }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { OAuthIntrospectionRequest } from './oauth-token-request';
|
|
2
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
3
|
+
/** Introspect a delegated token only for a confidential resource server that shares its resource. */
|
|
4
|
+
export declare function introspectOAuthToken(input: OAuthIntrospectionEndpointInput): Promise<OAuthIntrospectionResult>;
|
|
5
|
+
/** Execute one RFC 7662 request without registering a route. */
|
|
6
|
+
export declare function handleOAuthIntrospectionRequest(provider: ResolvedOAuthProviderConfig, input: OAuthIntrospectionHttpRequest): Promise<Response>;
|
|
7
|
+
export declare interface OAuthIntrospectionHttpRequest {
|
|
8
|
+
body: string
|
|
9
|
+
contentType?: string | null
|
|
10
|
+
authorization?: string | null
|
|
11
|
+
}
|
|
12
|
+
export declare interface OAuthIntrospectionEndpointInput {
|
|
13
|
+
provider: ResolvedOAuthProviderConfig
|
|
14
|
+
request: OAuthIntrospectionRequest
|
|
15
|
+
}
|
|
16
|
+
export declare interface OAuthIntrospectionInactive {
|
|
17
|
+
active: false
|
|
18
|
+
}
|
|
19
|
+
export declare interface OAuthIntrospectionActive {
|
|
20
|
+
active: true
|
|
21
|
+
clientId: string
|
|
22
|
+
subject: string
|
|
23
|
+
scope: string
|
|
24
|
+
tokenType: 'Bearer' | 'refresh_token'
|
|
25
|
+
audience: string[]
|
|
26
|
+
issuedAt: number
|
|
27
|
+
expiresAt: number
|
|
28
|
+
issuer: string
|
|
29
|
+
workspaceId: string | null
|
|
30
|
+
}
|
|
31
|
+
export type OAuthIntrospectionResult = OAuthIntrospectionInactive | OAuthIntrospectionActive;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import{createHash}from"node:crypto";import{db,getDatabaseDialect,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{OAuthTokenRequestError,parseOAuthIntrospectionRequest}from"./oauth-token-request";import{oauthTokenErrorResponse}from"./oauth-token-response";const inactive={active:!1};function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function activeFlag(value){return value===!1||value===0}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function epochSeconds(value){const parsed=parseSqlDateTime(value);if(!parsed||!Number.isSafeInteger(parsed.getTime()))return null;return Math.floor(parsed.getTime()/1000)}async function loadToken(request){if(!/^[a-f0-9]{80}$/.test(request.token))return null;const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(request.token),access=`
|
|
2
|
+
SELECT a.tokenable_type, a.tokenable_id, a.oauth_client_id, a.oauth_grant_id,
|
|
3
|
+
a.scopes, a.resources, a.audiences, a.workspace_id, a.revoked,
|
|
4
|
+
a.expires_at, a.created_at, g.revoked_at AS grant_revoked_at,
|
|
5
|
+
g.client_id AS grant_client_id, g.subject_type AS grant_subject_type,
|
|
6
|
+
g.subject_id AS grant_subject_id, c.revoked AS client_revoked
|
|
7
|
+
FROM oauth_access_tokens a
|
|
8
|
+
LEFT JOIN oauth_grants g ON g.id = a.oauth_grant_id
|
|
9
|
+
LEFT JOIN oauth_clients c ON c.id = a.oauth_client_id
|
|
10
|
+
WHERE a.token = ${sql.param(1)} AND a.oauth_grant_id IS NOT NULL
|
|
11
|
+
LIMIT 1
|
|
12
|
+
`,refresh=`
|
|
13
|
+
SELECT a.tokenable_type, a.tokenable_id, a.oauth_client_id, a.oauth_grant_id,
|
|
14
|
+
a.scopes, a.resources, a.audiences, a.workspace_id,
|
|
15
|
+
a.revoked, a.expires_at, a.created_at,
|
|
16
|
+
g.revoked_at AS grant_revoked_at, g.client_id AS grant_client_id,
|
|
17
|
+
g.subject_type AS grant_subject_type, g.subject_id AS grant_subject_id,
|
|
18
|
+
c.revoked AS client_revoked, r.revoked AS refresh_revoked,
|
|
19
|
+
r.expires_at AS refresh_expires_at
|
|
20
|
+
FROM oauth_refresh_tokens r
|
|
21
|
+
JOIN oauth_access_tokens a ON a.id = r.access_token_id
|
|
22
|
+
LEFT JOIN oauth_grants g ON g.id = a.oauth_grant_id
|
|
23
|
+
LEFT JOIN oauth_clients c ON c.id = a.oauth_client_id
|
|
24
|
+
WHERE r.token = ${sql.param(1)} AND a.oauth_grant_id IS NOT NULL
|
|
25
|
+
LIMIT 1
|
|
26
|
+
`,load=async(query)=>{return(await db.primary.unsafe(query,[hash]))[0]};if(request.tokenTypeHint==="refresh_token"){const row=await load(refresh);return row?{row,tokenType:"refresh_token"}:null}if(request.tokenTypeHint==="access_token"){const row=await load(access);return row?{row,tokenType:"Bearer"}:null}const accessRow=await load(access);if(accessRow)return{row:accessRow,tokenType:"Bearer"};const refreshRow=await load(refresh);return refreshRow?{row:refreshRow,tokenType:"refresh_token"}:null}export async function introspectOAuthToken(input){return await withAuthenticatedOAuthTokenClient(String(input.request.clientId),input.request.clientSecret,async(client)=>{if(!input.provider.introspection||client.type!=="confidential")return inactive;const loaded=await loadToken(input.request);if(!loaded)return inactive;const{row,tokenType}=loaded,scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),issuedAt=epochSeconds(row.created_at),expiresAt=epochSeconds(tokenType==="refresh_token"?row.refresh_expires_at??null:row.expires_at),clientId=Number(row.oauth_client_id),subjectId=Number(row.tokenable_id);if(!activeFlag(row.revoked)||!activeFlag(row.client_revoked)||row.grant_revoked_at!=null||row.grant_client_id==null||String(row.grant_client_id)!==String(row.oauth_client_id)||row.grant_subject_type!==row.tokenable_type||String(row.grant_subject_id)!==String(row.tokenable_id)||!scopes?.length||new Set(scopes).size!==scopes.length||!resources||!audiences||resources.length!==audiences.length||!Number.isSafeInteger(clientId)||clientId<=0||!Number.isSafeInteger(subjectId)||subjectId<=0||issuedAt==null||expiresAt==null||expiresAt<=Math.floor(Date.now()/1000)||tokenType==="refresh_token"&&!activeFlag(row.refresh_revoked)||!client.resources.some((resource)=>resources.includes(resource)))return inactive;if(input.provider.subjectEligibility&&!await input.provider.subjectEligibility({type:row.tokenable_type,id:subjectId,clientId,grantId:String(row.oauth_grant_id),workspaceId:row.workspace_id??null}))return inactive;return{active:!0,clientId:String(clientId),subject:`${row.tokenable_type}:${subjectId}`,scope:scopes.join(" "),tokenType,audience:audiences,issuedAt,expiresAt,issuer:input.provider.issuer,workspaceId:row.workspace_id}})??inactive}function introspectionResponse(result){if(!result.active)return new Response(JSON.stringify({active:!1}),{status:200,headers:{"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache"}});return new Response(JSON.stringify({active:!0,client_id:result.clientId,sub:result.subject,scope:result.scope,token_type:result.tokenType,aud:result.audience,exp:result.expiresAt,iat:result.issuedAt,iss:result.issuer,...result.workspaceId===null?{}:{workspace_id:result.workspaceId}}),{status:200,headers:{"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache"}})}export async function handleOAuthIntrospectionRequest(provider,input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??""),basicRealm:"oauth-introspect"};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthIntrospectionRequest(input.body,input.authorization);return introspectionResponse(await introspectOAuthToken({provider,request}))}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
package/dist/oauth-metadata.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export declare interface OAuthAuthorizationServerMetadata {
|
|
|
8
8
|
authorization_endpoint: string
|
|
9
9
|
token_endpoint: string
|
|
10
10
|
revocation_endpoint: string
|
|
11
|
+
introspection_endpoint?: string
|
|
11
12
|
response_types_supported: string[]
|
|
12
13
|
grant_types_supported: string[]
|
|
13
14
|
code_challenge_methods_supported: string[]
|
package/dist/oauth-metadata.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function 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)
|
|
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}
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
|
|
1
|
+
import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig, OAuthProviderSubjectEligibility } from '@stacksjs/types';
|
|
2
2
|
/** Resolve and validate application-owned workspace authority for consent. */
|
|
3
3
|
export declare function resolveOAuthConsentWorkspace(provider: ResolvedOAuthProviderConfig, context: OAuthProviderConsentWorkspaceContext): Promise<OAuthProviderConsentWorkspace | null>;
|
|
4
4
|
/**
|
|
@@ -18,9 +18,13 @@ export declare interface ResolvedOAuthProviderConfig {
|
|
|
18
18
|
authorization: string
|
|
19
19
|
token: string
|
|
20
20
|
revocation: string
|
|
21
|
+
introspection: string
|
|
21
22
|
}
|
|
22
23
|
responseTypes: readonly ['code']
|
|
23
|
-
grantTypes: readonly
|
|
24
|
+
grantTypes: readonly ('authorization_code' | 'refresh_token' | 'client_credentials')[]
|
|
25
|
+
clientCredentials: boolean
|
|
26
|
+
introspection: boolean
|
|
27
|
+
subjectEligibility?: OAuthProviderSubjectEligibility
|
|
24
28
|
codeChallengeMethods: readonly ['S256']
|
|
25
29
|
clientTypes: readonly ('confidential' | 'public')[]
|
|
26
30
|
scopes: Record<string, OAuthProviderScopeConfig>
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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"},RESERVED_AUTH_GET_PATHS=new Set(["/generate-registration-options","/generate-authentication-options"]),RESERVED_AUTH_POST_PATHS=new Set(["/login","/register","/verify-registration","/verify-authentication","/generate-two-factor-secret","/enable-two-factor","/disable-two-factor","/verify-two-factor-login"]);function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function assertEndpointRouteAvailable(name,value,methods){const pathname=new URL(value).pathname,reservedNamespace=pathname==="/auth"||pathname.startsWith("/auth/"),reservedMethod=methods.some((method)=>method==="GET"?RESERVED_AUTH_GET_PATHS.has(pathname):RESERVED_AUTH_POST_PATHS.has(pathname));if(reservedNamespace||reservedMethod)throw Error(`auth.oauthProvider ${name} endpoint must not shadow a reserved auth route: ${pathname}`)}function positiveLifetime(name,value){if(!Number.isSafeInteger(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive safe integer of milliseconds.`);if(!Number.isFinite(new Date(Date.now()+value).getTime()))throw Error(`auth.oauthProvider.lifetimes.${name} is outside the supported date range.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isSafeInteger(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative safe integer of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");const resolveWorkspace=options?.resolveWorkspace;if(resolveWorkspace!==void 0&&typeof resolveWorkspace!=="function")throw Error("auth.oauthProvider.consent.resolveWorkspace must be a function.");return{rememberFor,view,...resolveWorkspace?{resolveWorkspace}:{}}}export async function resolveOAuthConsentWorkspace(provider,context){const resolved=await provider.consent.resolveWorkspace?.(context)??null;if(resolved===null)return null;if(typeof resolved.id!=="string"||!resolved.id||resolved.id.length>255||/[\u0000-\u001F\u007F]/.test(resolved.id))throw Error("OAuth consent workspace id must contain 1 to 255 characters without controls.");if(typeof resolved.label!=="string"||!resolved.label.trim()||resolved.label.length>255||/[\u0000-\u001F\u007F]/.test(resolved.label))throw Error("OAuth consent workspace label must contain 1 to 255 characters without controls.");return{id:resolved.id,label:resolved.label.trim()}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)},endpoints={authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation)};if(new Set(Object.values(endpoints)).size!==Object.keys(endpoints).length)throw Error("auth.oauthProvider endpoints must each use a unique URL.");assertEndpointRouteAvailable("authorization",endpoints.authorization,["GET","POST"]);assertEndpointRouteAvailable("token",endpoints.token,["POST"]);assertEndpointRouteAvailable("revocation",endpoints.revocation,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,responseTypes:["code"],grantTypes:
|
|
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)}}
|
package/dist/oauth-pruning.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{db,getDatabaseDialect,markContextWrote,mutationCount,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";export async function pruneOAuthAuthorizationArtifacts(options={}){const now=options.now??new Date,consumedRetentionMs=options.consumedRetentionMs??604800000;if(!Number.isFinite(now.getTime()))throw TypeError("OAuth authorization prune time must be a valid date.");if(!Number.
|
|
1
|
+
import{db,getDatabaseDialect,markContextWrote,mutationCount,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";export async function pruneOAuthAuthorizationArtifacts(options={}){const now=options.now??new Date,consumedRetentionMs=options.consumedRetentionMs??604800000;if(!Number.isFinite(now.getTime()))throw TypeError("OAuth authorization prune time must be a valid date.");if(!Number.isSafeInteger(consumedRetentionMs)||consumedRetentionMs<0)throw TypeError("OAuth authorization consumed retention must be a non-negative safe integer of milliseconds.");const sql=sqlHelpers(getDatabaseDialect()),nowSql=sqlDateTime(now),consumedBeforeDate=new Date(now.getTime()-consumedRetentionMs);if(!Number.isFinite(consumedBeforeDate.getTime()))throw TypeError("OAuth authorization consumed retention is outside the supported date range.");const consumedBefore=sqlDateTime(consumedBeforeDate),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,requests=await trx.unsafe(`
|
|
2
2
|
DELETE FROM oauth_authorization_requests
|
|
3
3
|
WHERE expires_at <= ${sql.param(1)}
|
|
4
4
|
OR (consumed_at IS NOT NULL AND consumed_at <= ${sql.param(2)})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input,dependencies){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
|
1
|
+
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,9 +1,12 @@
|
|
|
1
1
|
import type { AuthorizationCodeGrant, AuthorizationCodeRedemption, AuthorizationCodeResult } from './oauth-authorization-codes';
|
|
2
|
-
import type {
|
|
2
|
+
import type { OAuthProviderSubjectEligibility } from '@stacksjs/types';
|
|
3
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
3
4
|
/** Atomically consume an authorization code and mint its grant-bound token pair. */
|
|
4
5
|
export declare function exchangeAuthorizationCode(input: ExchangeAuthorizationCodeInput): Promise<AuthorizationCodeResult<DelegatedTokenPair>>;
|
|
5
6
|
/** Authenticate the provider client and exchange its code under one policy lock. */
|
|
6
7
|
export declare function exchangeOAuthAuthorizationCode(input: ExchangeOAuthAuthorizationCodeInput): Promise<OAuthAuthorizationCodeExchangeResult>;
|
|
8
|
+
/** Authenticate a confidential machine client and mint one access-only grant. */
|
|
9
|
+
export declare function exchangeOAuthClientCredentials(input: ExchangeOAuthClientCredentialsInput): Promise<OAuthClientCredentialsExchangeResult>;
|
|
7
10
|
/** Rotate one delegated refresh token and contain reuse to its token family. */
|
|
8
11
|
export declare function refreshOAuthDelegatedToken(input: RefreshOAuthDelegatedTokenInput): Promise<OAuthRefreshTokenExchangeResult>;
|
|
9
12
|
declare const invalidClient: {
|
|
@@ -37,6 +40,13 @@ export declare interface RefreshOAuthDelegatedTokenInput {
|
|
|
37
40
|
refreshTokenLifetimeMs: number
|
|
38
41
|
isSubjectEligible?: OAuthSubjectEligibility
|
|
39
42
|
}
|
|
43
|
+
export declare interface ExchangeOAuthClientCredentialsInput {
|
|
44
|
+
provider: ResolvedOAuthProviderConfig
|
|
45
|
+
clientId: number
|
|
46
|
+
clientSecret?: string
|
|
47
|
+
scopes?: readonly string[]
|
|
48
|
+
accessTokenLifetimeMs: number
|
|
49
|
+
}
|
|
40
50
|
export declare interface DelegatedTokenPair {
|
|
41
51
|
accessToken: string
|
|
42
52
|
refreshToken?: string
|
|
@@ -51,7 +61,7 @@ export declare interface DelegatedTokenPair {
|
|
|
51
61
|
audiences: string[]
|
|
52
62
|
workspaceId: string | null
|
|
53
63
|
}
|
|
54
|
-
export type OAuthSubjectEligibility =
|
|
64
|
+
export type OAuthSubjectEligibility = OAuthProviderSubjectEligibility;
|
|
55
65
|
declare type DelegatedGrant = Omit<AuthorizationCodeGrant, 'redirectUri'>;
|
|
56
66
|
export type OAuthAuthorizationCodeExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
|
|
57
67
|
| typeof invalidClient;
|
|
@@ -59,6 +69,10 @@ export type OAuthRefreshTokenExchangeResult = AuthorizationCodeResult<DelegatedT
|
|
|
59
69
|
| typeof invalidClient
|
|
60
70
|
| typeof invalidScope
|
|
61
71
|
| typeof unauthorizedClient;
|
|
72
|
+
export type OAuthClientCredentialsExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
|
|
73
|
+
| typeof invalidClient
|
|
74
|
+
| typeof invalidScope
|
|
75
|
+
| typeof unauthorizedClient;
|
|
62
76
|
declare class InactiveOAuthClientError extends Error {
|
|
63
77
|
|
|
64
78
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{disconnectOAuthGrant}from"./oauth-grants";import{verifyS256CodeChallenge}from"./oauth-pkce";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"},inactiveSubject=Symbol("inactive-oauth-subject");function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}async function containAuthorizationCodeReplay(input){if(!/^[A-Za-z0-9_-]{43}$/.test(input.code))return;const sql=sqlHelpers(getDatabaseDialect()),code=(await db.unsafe(`
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{createOAuthGrant,disconnectOAuthGrant}from"./oauth-grants";import{verifyS256CodeChallenge}from"./oauth-pkce";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"},inactiveSubject=Symbol("inactive-oauth-subject");function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}async function containAuthorizationCodeReplay(input){if(!/^[A-Za-z0-9_-]{43}$/.test(input.code))return;const sql=sqlHelpers(getDatabaseDialect()),code=(await db.unsafe(`
|
|
2
2
|
SELECT grant_id, client_id, subject_type, subject_id, redirect_uri,
|
|
3
3
|
code_challenge, code_challenge_method, consumed_at
|
|
4
4
|
FROM oauth_auth_codes
|
|
@@ -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 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),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
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export declare function parseOAuthTokenRequest(body: string, authorization?: string | null): OAuthTokenRequest;
|
|
3
3
|
/** Parse one RFC 7009 revocation request through the token endpoint's client-authentication rules. */
|
|
4
4
|
export declare function parseOAuthRevocationRequest(body: string, authorization?: string | null): OAuthRevocationRequest;
|
|
5
|
+
/** Parse one RFC 7662 introspection request using confidential Basic authentication. */
|
|
6
|
+
export declare function parseOAuthIntrospectionRequest(body: string, authorization?: string | null): OAuthIntrospectionRequest;
|
|
5
7
|
export declare interface OAuthClientCredentials {
|
|
6
8
|
clientId: number
|
|
7
9
|
clientSecret?: string
|
|
@@ -21,15 +23,23 @@ export declare interface OAuthRefreshTokenRequest {
|
|
|
21
23
|
refreshToken: string
|
|
22
24
|
scopes?: string[]
|
|
23
25
|
}
|
|
26
|
+
export declare interface OAuthClientCredentialsTokenRequest extends OAuthClientCredentials {
|
|
27
|
+
grantType: 'client_credentials'
|
|
28
|
+
scopes?: string[]
|
|
29
|
+
}
|
|
24
30
|
export declare interface OAuthRevocationRequest extends OAuthClientCredentials {
|
|
25
31
|
token: string
|
|
26
32
|
tokenTypeHint?: 'access_token' | 'refresh_token'
|
|
27
33
|
}
|
|
34
|
+
export declare interface OAuthIntrospectionRequest extends OAuthClientCredentials {
|
|
35
|
+
token: string
|
|
36
|
+
tokenTypeHint?: 'access_token' | 'refresh_token'
|
|
37
|
+
}
|
|
28
38
|
export type OAuthTokenRequestErrorCode = | 'invalid_client'
|
|
29
39
|
| 'invalid_request'
|
|
30
40
|
| 'unsupported_grant_type'
|
|
31
41
|
| 'unsupported_token_type';
|
|
32
|
-
export type OAuthTokenRequest = OAuthAuthorizationCodeTokenRequest | OAuthRefreshTokenRequest;
|
|
42
|
+
export type OAuthTokenRequest = OAuthAuthorizationCodeTokenRequest | OAuthRefreshTokenRequest | OAuthClientCredentialsTokenRequest;
|
|
33
43
|
export declare class OAuthTokenRequestError extends Error {
|
|
34
44
|
public readonly error: OAuthTokenRequestErrorCode;
|
|
35
45
|
constructor(error: OAuthTokenRequestErrorCode, message: 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
|
|
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}:{}}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/auth",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.35",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,18 +58,18 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@stacksjs/bun-router": "^0.1.18",
|
|
61
|
-
"@stacksjs/cache": "0.75.
|
|
62
|
-
"@stacksjs/config": "0.75.
|
|
63
|
-
"@stacksjs/database": "0.75.
|
|
64
|
-
"@stacksjs/email": "0.75.
|
|
65
|
-
"@stacksjs/env": "0.75.
|
|
66
|
-
"@stacksjs/error-handling": "0.75.
|
|
67
|
-
"@stacksjs/logging": "0.75.
|
|
68
|
-
"@stacksjs/orm": "0.75.
|
|
69
|
-
"@stacksjs/path": "0.75.
|
|
70
|
-
"@stacksjs/router": "0.75.
|
|
71
|
-
"@stacksjs/security": "0.75.
|
|
72
|
-
"@stacksjs/storage": "0.75.
|
|
61
|
+
"@stacksjs/cache": "0.75.35",
|
|
62
|
+
"@stacksjs/config": "0.75.35",
|
|
63
|
+
"@stacksjs/database": "0.75.35",
|
|
64
|
+
"@stacksjs/email": "0.75.35",
|
|
65
|
+
"@stacksjs/env": "0.75.35",
|
|
66
|
+
"@stacksjs/error-handling": "0.75.35",
|
|
67
|
+
"@stacksjs/logging": "0.75.35",
|
|
68
|
+
"@stacksjs/orm": "0.75.35",
|
|
69
|
+
"@stacksjs/path": "0.75.35",
|
|
70
|
+
"@stacksjs/router": "0.75.35",
|
|
71
|
+
"@stacksjs/security": "0.75.35",
|
|
72
|
+
"@stacksjs/storage": "0.75.35",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|