@stacksjs/auth 0.75.191 → 0.75.193
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authentication.js +3 -8
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-codes.d.ts +1 -0
- package/dist/oauth-authorization-codes.js +1 -1
- package/dist/oauth-authorization-endpoint.js +1 -1
- package/dist/oauth-authorization-response.d.ts +2 -2
- package/dist/oauth-authorization-response.js +1 -1
- package/dist/oauth-client-registration.d.ts +3 -1
- package/dist/oauth-client-registration.js +1 -1
- package/dist/oauth-consent.d.ts +2 -0
- package/dist/oauth-consent.js +2 -2
- package/dist/oauth-dynamic-registration.d.ts +5 -0
- package/dist/oauth-dynamic-registration.js +1 -0
- package/dist/oauth-metadata.d.ts +2 -0
- package/dist/oauth-metadata.js +1 -1
- package/dist/oauth-protected-resource.d.ts +13 -0
- package/dist/oauth-protected-resource.js +1 -0
- package/dist/oauth-provider.d.ts +2 -0
- package/dist/oauth-provider.js +1 -1
- package/dist/oauth-server.d.ts +26 -0
- package/dist/oauth-server.js +1 -0
- package/dist/oauth-token-endpoint.js +1 -1
- package/dist/oauth-token-exchange.d.ts +2 -0
- package/dist/oauth-token-exchange.js +2 -2
- package/dist/oauth-token-request.d.ts +3 -0
- package/dist/oauth-token-request.js +1 -1
- package/package.json +14 -13
package/dist/authentication.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout,sessionUser}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at==null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,getPasswordChangedAt,getTokenOwnerCredentialState,isIssuedBeforePasswordChange,parseScopes}from"./tokens";async function
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{formatDate,User}from"@stacksjs/orm";import{getCurrentRequest,request}from"@stacksjs/router";import{requestToken}from"./request-token";import{sessionLogout,sessionUser}from"./session-auth";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{Buffer}from"node:buffer";import{createHash,timingSafeEqual}from"node:crypto";import{decrypt,encrypt,verifyHash}from"@stacksjs/security";import{log}from"@stacksjs/logging";import{DUMMY_BCRYPT_HASH}from"./internal-constants";import{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function accessTokenExceededIdleTimeout(accessToken){const idleMs=config.auth?.idleTimeout??0;if(idleMs<=0)return!1;const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);return lastSeen!==null&&Date.now()-lastSeen.getTime()>idleMs}async function revokeObservedIdleAccessToken(accessToken){let revoke=db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).where("revoked","=",!1);revoke=accessToken.updated_at==null?revoke.whereNull("updated_at"):revoke.where("updated_at","=",accessToken.updated_at);await revoke.execute()}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,deleteRevokedTokens,getPasswordChangedAt,getTokenOwnerCredentialState,isIssuedBeforePasswordChange,parseScopes}from"./tokens";async function ordinaryAuthorizationIntact(tokenId){const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
2
2
|
SELECT t.id FROM oauth_access_tokens t
|
|
3
3
|
LEFT JOIN oauth_clients c ON c.id = t.oauth_client_id
|
|
4
|
-
LEFT JOIN oauth_grants g ON g.id = t.oauth_grant_id
|
|
5
4
|
WHERE t.id = ${sql.param(1)}
|
|
5
|
+
AND t.oauth_grant_id IS NULL
|
|
6
6
|
AND (c.id IS NULL OR c.revoked = ${sql.boolFalse})
|
|
7
|
-
AND (t.oauth_grant_id IS NULL OR (
|
|
8
|
-
g.id IS NOT NULL
|
|
9
|
-
AND g.revoked_at IS NULL
|
|
10
|
-
AND g.client_id = t.oauth_client_id
|
|
11
|
-
))
|
|
12
7
|
LIMIT 1
|
|
13
|
-
`,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await oauthAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await oauthAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
|
8
|
+
`,[tokenId])).length>0}export class Auth{static getBearerToken(){return requestToken(request)}static parseToken(token){const firstColonIndex=token.indexOf(":");if(firstColonIndex===-1)return null;const plainToken=token.substring(0,firstColonIndex),encryptedId=token.substring(firstColonIndex+1);if(!plainToken||!encryptedId)return null;return{plainToken,encryptedId}}static async getClientSecret(){const state=authStateOrNull();if(state?.clientSecret)return state.clientSecret;const client=await this.getPersonalAccessClient();if(state)state.clientSecret=client.secret;return client.secret}static async encryptTokenId(id){return await encrypt(String(id))}static async decryptTokenId(encryptedId){try{return await decrypt(encryptedId)}catch{try{const clientSecret=await this.getClientSecret();return await decrypt(encryptedId,clientSecret)}catch{return null}}}static async getPersonalAccessClient(){try{const client=await db.primary.selectFrom("oauth_clients").where("personal_access_client","=",!0).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst();if(!client)throw new HttpError(500,"No personal access client found. Please run `./buddy auth:setup` first.");return client}catch(error){if(error instanceof Error&&error.message.includes("does not exist"))throw new HttpError(500,"OAuth tables not found. Please run `./buddy auth:setup` first.");throw error}}static async verifiedClient(clientId,clientSecret){const client=await db.primary.selectFrom("oauth_clients").where("id","=",clientId).where("revoked","=",!1).select(["id","secret"]).executeTakeFirst(),provided=Buffer.from(clientSecret);if(!client?.secret){const dummy=Buffer.alloc(Math.max(provided.length,1)),padded=provided.length>0?provided:Buffer.alloc(1);timingSafeEqual(dummy,padded);return null}const stored=String(client.secret);if(stored.startsWith("$2"))return await verifyHash(clientSecret,stored)?client:null;const storedBuf=Buffer.from(stored);if(storedBuf.length!==provided.length){timingSafeEqual(storedBuf,storedBuf);return null}return timingSafeEqual(storedBuf,provided)?client:null}static async getTokenFromId(tokenId){const result=await db.primary.selectFrom("oauth_access_tokens").where("id","=",tokenId).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!result)return null;return this.tokenFromRow(result)}static tokenFromRow(token){return{id:token.id,userId:token.user_id,clientId:token.oauth_client_id,name:token.name||"auth-token",scopes:parseScopes(token.scopes),abilities:parseScopes(token.scopes),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}}static async attempt(credentials){const verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??(config.auth.refreshTokenExpiry??2592000000)/86400000;log.debug(`[auth] Creating token for user#${user.id}: ${name}`);const result=await createRawToken(user.id,name,abilities,{expiresAt,clientId:options?.clientId,withRefreshToken:options?.withRefreshToken!==!1,refreshExpiresInDays,userAgent:options?.userAgent??null,ipAddress:options?.ipAddress??null}),plainTextToken=result.plainTextToken;return{accessToken:{id:result.accessToken.id,userId:result.accessToken.userId,clientId:result.accessToken.clientId,name:result.accessToken.name,scopes:result.accessToken.scopes,abilities,expiresAt:result.accessToken.expiresAt??expiresAt,createdAt:result.accessToken.createdAt,updatedAt:result.accessToken.updatedAt,revoked:result.accessToken.revoked,plainTextToken},plainTextToken,refreshToken:result.refreshToken,expiresIn:result.expiresIn}}static async createToken(user,name=config.auth.defaultTokenName||"auth-token",abilities=config.auth.defaultAbilities||["*"]){const{plainTextToken}=await this.createTokenForUser(user,{name,abilities});return plainTextToken}static async requestUserTokenWithClient(credentials,clientId,clientSecret){const client=await this.verifiedClient(clientId,clientSecret);if(!client)throw new HttpError(401,"Invalid client credentials");const result=await this.withVerifiedCredentials(credentials,async(user)=>{let currentQuery=db.primary.selectFrom("oauth_clients").where("id","=",client.id).select(["secret","revoked"]);if(getDatabaseDialect()!=="sqlite")currentQuery=currentQuery.sharedLock();const current=await currentQuery.executeTakeFirst();if(!current||current.revoked||current.secret!==client.secret)throw new HttpError(401,"Invalid client credentials");const issued=await this.createTokenForUser(user,{name:"user-auth-token",clientId:client.id});return{user,token:issued.plainTextToken}}),state=authStateOrNull();if(result&&state)state.authUser=result.user;return result?{token:result.token}:null}static async requestToken(credentials,clientId,clientSecret){return this.requestUserTokenWithClient(credentials,clientId,clientSecret)}static async validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return!1;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return!1;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return!1}await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();log.debug(`[auth] Token validated for token#${accessToken.id}`);return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).select(["id","user_id","oauth_client_id","name","scopes","tokenable_type","tokenable_id","revoked","expires_at","created_at","updated_at"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;if(accessToken.revoked)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;if(accessTokenExceededIdleTimeout(accessToken)){await revokeObservedIdleAccessToken(accessToken);return}if(!accessToken?.tokenable_id)return;const user=await User.find(accessToken.tokenable_id);if(!user)return;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return;const updatedAt=formatDate(new Date);await db.updateTable("oauth_access_tokens").set({updated_at:updatedAt}).where("id","=",accessToken.id).execute();const stateForToken=authStateOrNull();if(stateForToken)stateForToken.currentToken=this.tokenFromRow({...accessToken,updated_at:updatedAt});return user}static async currentAccessToken(){const state=authStateOrNull();if(state?.currentToken){const cached=state.currentToken;if(cached.revoked||cached.expiresAt!=null&&!(cached.expiresAt.getTime()>Date.now()))return;return cached}const bearerToken=this.getBearerToken();if(!bearerToken)return;const accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashToken(bearerToken)).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at","tokenable_type","tokenable_id"]).executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE||accessToken.revoked)return;if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return;const idleMs=config.auth?.idleTimeout??0,lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(idleMs>0&&lastSeen&&Date.now()-lastSeen.getTime()>idleMs)return;if(!await ordinaryAuthorizationIntact(Number(accessToken.id)))return;const owner=await getTokenOwnerCredentialState(accessToken.tokenable_id);if(!owner.exists||isIssuedBeforePasswordChange(accessToken.created_at,owner.passwordChangedAt))return;const token=this.tokenFromRow(accessToken);if(state)state.currentToken=token;return token}static async tokenCan(ability){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return token.abilities.includes(ability)}static async tokenCant(ability){return!await this.tokenCan(ability)}static async tokenAbilities(){return(await this.currentAccessToken())?.abilities??[]}static async tokenCanAll(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.every((a)=>token.abilities.includes(a))}static async tokenCanAny(abilities){const token=await this.currentAccessToken();if(!token)return!1;if(token.abilities.includes("*"))return!0;return abilities.some((a)=>token.abilities.includes(a))}static async tokens(userId){const uid=userId??await this.id();if(!uid)return[];return(await db.primary.selectFrom("oauth_access_tokens").where("tokenable_id","=",uid).where("tokenable_type","=",DEFAULT_TOKENABLE_TYPE).where("revoked","=",!1).select(["id","user_id","oauth_client_id","name","scopes","revoked","expires_at","created_at","updated_at"]).execute()).map((token)=>({id:Number(token.id),userId:Number(token.user_id),clientId:Number(token.oauth_client_id),name:String(token.name||"auth-token"),scopes:parseScopes(String(token.scopes??"")),abilities:parseScopes(String(token.scopes??"")),expiresAt:token.expires_at==null?null:tokenDate(token.expires_at),...tokenTimestamps(token),revoked:!!token.revoked}))}static async revokeToken(token){await revokeTokenPair({hash:hashToken(token)})}static async revokeTokenById(tokenId){await revokeTokenPair({id:tokenId})}static async revokeAllTokens(userId){const uid=userId??await this.id();if(!uid)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE)}static async revokeOtherTokens(userId){const uid=userId??await this.id();if(!uid)return;const currentToken=await this.currentAccessToken();if(!currentToken)return;await revokeTokenPairs(uid,DEFAULT_TOKENABLE_TYPE,currentToken.id)}static async pruneExpiredTokens(){return deleteExpiredTokens()}static async pruneRevokedTokens(){return deleteRevokedTokens(-1)}static async rotateToken(oldToken){const{findToken:findRawToken,revokeToken:revokeRawToken}=await import("./tokens"),candidate=await findRawToken(oldToken);if(!candidate||candidate.grantId!=null)return null;return db.transaction(async()=>{let query=db.primary.selectFrom("oauth_access_tokens").where("id","=",candidate.id).select(["id","revoked","tokenable_type","tokenable_id","expires_at","name","scopes"]);if(getDatabaseDialect()!=="sqlite")query=query.lockForUpdate();const owner=await query.executeTakeFirst();if(!owner||owner.revoked||owner.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return null;const deadline=parseSqlDateTime(owner.expires_at);if(owner.expires_at!=null&&(!deadline||deadline.getTime()<=Date.now()))return null;const existing=await findRawToken(oldToken);if(!existing||existing.grantId!=null||Number(existing.id)!==Number(owner.id))return null;const user=await User.find(Number(owner.tokenable_id));if(!user)return null;await revokeRawToken(oldToken);return(await this.createTokenForUser(user,{name:String(owner.name||"access-token"),abilities:parseScopes(owner.scopes),expiresAt:deadline??new Date(Date.now()+(config.auth.tokenExpiry??3600000)),withRefreshToken:!1})).plainTextToken})}static async findToken(tokenId){return this.getTokenFromId(tokenId)}static async once(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
package/dist/index.d.ts
CHANGED
|
@@ -68,3 +68,6 @@ export * from './two-factor';
|
|
|
68
68
|
export * from './team';
|
|
69
69
|
// Referral codes, signup attribution, and server-verified conversions.
|
|
70
70
|
export * from './referrals';
|
|
71
|
+
export * from './oauth-dynamic-registration';
|
|
72
|
+
export * from './oauth-protected-resource';
|
|
73
|
+
export * from './oauth-server';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
1
|
+
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./credential-user";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-introspection";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./passkeys";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";export*from"./oauth-dynamic-registration";export*from"./oauth-protected-resource";export*from"./oauth-server";
|
|
@@ -20,7 +20,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
20
20
|
JOIN oauth_grants g ON g.id = c.grant_id AND g.revoked_at IS NULL
|
|
21
21
|
WHERE c.code_hash = ${sql.param(1)}
|
|
22
22
|
LIMIT 1${lock}
|
|
23
|
-
`,[hash]))[0];if(!row||row.consumed_at!=null||String(row.client_id)!==String(expected.clientId)||row.redirect_uri!==expected.redirectUri||row.code_challenge_method!=="S256"||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now()||!await verifyS256CodeChallenge(expected.codeVerifier,row.code_challenge))return invalidGrant;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),grantScopes=storedValues(row.grant_scopes),grantResources=storedValues(row.grant_resources),grantAudiences=storedValues(row.grant_audiences),clientId=Number(row.client_id),subjectId=Number(row.subject_id);if(!row.grant_id||!scopes||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||!validIdentifier(clientId)||!validIdentifier(subjectId)||String(row.grant_client_id)!==String(row.client_id)||row.grant_subject_type!==row.subject_type||String(row.grant_subject_id)!==String(row.subject_id)||JSON.stringify(grantScopes)!==JSON.stringify(scopes)||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||row.grant_workspace_id!==row.workspace_id)return invalidGrant;const now=sqlDateTime(new Date),claimed=await trx.unsafe(`
|
|
23
|
+
`,[hash]))[0];if(!row||expected.audiences&&(!expected.audiences.length||new Set(expected.audiences).size!==expected.audiences.length||expected.audiences.length!==storedValues(row.audiences)?.length||expected.audiences.some((audience)=>!storedValues(row.audiences)?.includes(audience)))||row.consumed_at!=null||String(row.client_id)!==String(expected.clientId)||row.redirect_uri!==expected.redirectUri||row.code_challenge_method!=="S256"||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now()||!await verifyS256CodeChallenge(expected.codeVerifier,row.code_challenge))return invalidGrant;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),grantScopes=storedValues(row.grant_scopes),grantResources=storedValues(row.grant_resources),grantAudiences=storedValues(row.grant_audiences),clientId=Number(row.client_id),subjectId=Number(row.subject_id);if(!row.grant_id||!scopes||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||!validIdentifier(clientId)||!validIdentifier(subjectId)||String(row.grant_client_id)!==String(row.client_id)||row.grant_subject_type!==row.subject_type||String(row.grant_subject_id)!==String(row.subject_id)||JSON.stringify(grantScopes)!==JSON.stringify(scopes)||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||row.grant_workspace_id!==row.workspace_id)return invalidGrant;const now=sqlDateTime(new Date),claimed=await trx.unsafe(`
|
|
24
24
|
UPDATE oauth_auth_codes
|
|
25
25
|
SET consumed_at = ${sql.param(1)}
|
|
26
26
|
WHERE code_hash = ${sql.param(2)}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{bindOAuthAuthorizationRequestWorkspace,createOAuthAuthorizationRequestSession,isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest,reuseOAuthAuthorizationRequestSession}from"./oauth-consent";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}if(input.identity&&input.subject&&input.provider.consent.resolveWorkspace){if(!await bindOAuthAuthorizationRequestWorkspace(requestId,browser.id,input.subject.workspaceId??null))return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"})}if(input.identity&&input.subject&&input.provider.consent.rememberFor>0)try{const reused=await(input.dependencies.reuseConsent??reuseOAuthAuthorizationRequestSession)({provider:input.provider,requestId,browserSessionId:browser.id,subjectType:input.subject.type,subjectId:input.subject.id,workspaceId:input.subject.workspaceId,resolveWorkspaceId:input.subject.resolveWorkspaceId});if(reused){if(!reused.ok)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const protocol=oauthAuthorizationConsentResponse(reused.value),headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await input.request
|
|
1
|
+
import{readTextBody,RequestBodyError}from"@stacksjs/router/body";import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{bindOAuthAuthorizationRequestWorkspace,createOAuthAuthorizationRequestSession,isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest,reuseOAuthAuthorizationRequestSession}from"./oauth-consent";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error,input.provider.issuer);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}if(input.identity&&input.subject&&input.provider.consent.resolveWorkspace){if(!await bindOAuthAuthorizationRequestWorkspace(requestId,browser.id,input.subject.workspaceId??null))return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"})}if(input.identity&&input.subject&&input.provider.consent.rememberFor>0)try{const reused=await(input.dependencies.reuseConsent??reuseOAuthAuthorizationRequestSession)({provider:input.provider,requestId,browserSessionId:browser.id,subjectType:input.subject.type,subjectId:input.subject.id,workspaceId:input.subject.workspaceId,resolveWorkspaceId:input.subject.resolveWorkspaceId});if(reused){if(!reused.ok)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const protocol=oauthAuthorizationConsentResponse(reused.value,input.provider.issuer),headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error,input.provider.issuer);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await readTextBody(input.request,4096))}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);if(error instanceof RequestBodyError)return localAuthorizationError(error.status);throw error}const json=input.request.headers.get("accept")?.split(",").some((value)=>value.trim().split(";")[0]==="application/json")===!0,browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value,input.provider.issuer,{json}):localAuthorizationError(400)}let approved;try{approved=await approveOAuthAuthorizationRequestSession({provider:input.provider,requestId:consent.requestId,browserSessionId,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId:input.workspaceId,resolveWorkspaceId:input.resolveWorkspaceId,authorizationCodeLifetimeMs:input.provider.lifetimes.authorizationCode,approvedScopes:consent.approvedScopes})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error,input.provider.issuer)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value,input.provider.issuer,{json}):localAuthorizationError(400)}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { OAuthAuthorizationRequestError } from './oauth-authorization';
|
|
2
2
|
import type { OAuthAuthorizationConsentResult, OAuthAuthorizationDenialResult } from './oauth-consent';
|
|
3
3
|
/** Redirect an approved or denied consent result to its already validated callback. */
|
|
4
|
-
export declare function oauthAuthorizationConsentResponse(result: OAuthAuthorizationConsentResult | OAuthAuthorizationDenialResult): Response;
|
|
4
|
+
export declare function oauthAuthorizationConsentResponse(result: OAuthAuthorizationConsentResult | OAuthAuthorizationDenialResult, issuer?: string, options?: { json?: boolean }): Response;
|
|
5
5
|
/** Redirect a protocol failure only when validation produced a trusted callback. */
|
|
6
|
-
export declare function oauthAuthorizationRequestErrorResponse(error: OAuthAuthorizationRequestError): Response | null;
|
|
6
|
+
export declare function oauthAuthorizationRequestErrorResponse(error: OAuthAuthorizationRequestError, issuer?: string): Response | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isValidOAuthRedirectUri,OAuthAuthorizationRequestError}from"./oauth-authorization";const AUTHORIZATION_REDIRECT_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"},RESPONSE_PARAMETERS=["code","error","error_description","error_uri","state"];function redirectResponse(redirectUri,result,state){if(!isValidOAuthRedirectUri(redirectUri))throw TypeError("OAuth authorization response requires a safe redirect URI.");const target=new URL(redirectUri);for(const parameter of RESPONSE_PARAMETERS)target.searchParams.delete(parameter);if("code"in result)target.searchParams.set("code",result.code);else target.searchParams.set("error",result.error);if(state!==null)target.searchParams.set("state",state);return new Response(null,{status:302,headers:{...AUTHORIZATION_REDIRECT_HEADERS,Location:target.toString()}})}export function oauthAuthorizationConsentResponse(result){
|
|
1
|
+
import{isValidOAuthRedirectUri,OAuthAuthorizationRequestError}from"./oauth-authorization";const AUTHORIZATION_REDIRECT_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"},RESPONSE_PARAMETERS=["iss","code","error","error_description","error_uri","state"];function redirectResponse(redirectUri,result,state,issuer){if(!isValidOAuthRedirectUri(redirectUri))throw TypeError("OAuth authorization response requires a safe redirect URI.");const target=new URL(redirectUri);for(const parameter of RESPONSE_PARAMETERS)target.searchParams.delete(parameter);if(issuer)target.searchParams.set("iss",issuer);if("code"in result)target.searchParams.set("code",result.code);else target.searchParams.set("error",result.error);if(state!==null)target.searchParams.set("state",state);return new Response(null,{status:302,headers:{...AUTHORIZATION_REDIRECT_HEADERS,Location:target.toString()}})}export function oauthAuthorizationConsentResponse(result,issuer,options={}){const redirect="code"in result?redirectResponse(result.redirectUri,{code:result.code},result.state,issuer):redirectResponse(result.redirectUri,{error:result.error},result.state,issuer);return options.json?Response.json({redirect_uri:redirect.headers.get("Location")},{headers:AUTHORIZATION_REDIRECT_HEADERS}):redirect}export function oauthAuthorizationRequestErrorResponse(error,issuer){if(error.redirectUri===null)return null;return redirectResponse(error.redirectUri,{error:error.code},error.state,issuer)}
|
|
@@ -15,6 +15,8 @@ export declare function updateOAuthClient(provider: ResolvedOAuthProviderConfig,
|
|
|
15
15
|
export declare function withAuthenticatedOAuthTokenClient<T>(clientId: string, clientSecret: string | undefined, complete: (client: OAuthAuthorizationClientRegistration) => Promise<T>): Promise<T | null>;
|
|
16
16
|
/** Validate and persist one owner-managed provider client. */
|
|
17
17
|
export declare function registerOAuthClient(provider: ResolvedOAuthProviderConfig, ownerId: number, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
|
|
18
|
+
/** Provider-controlled anonymous clients cannot acquire owner-management authority. */
|
|
19
|
+
export declare function registerDynamicOAuthClient(provider: ResolvedOAuthProviderConfig, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
|
|
18
20
|
/** Rotate one active confidential client's secret and reveal the replacement once. */
|
|
19
21
|
export declare function rotateOAuthClientSecret(ownerId: number, clientId: number): Promise<string | null>;
|
|
20
22
|
/**
|
|
@@ -56,7 +58,7 @@ export declare interface ValidatedOAuthClientRegistration extends OAuthClientReg
|
|
|
56
58
|
}
|
|
57
59
|
export declare interface RegisteredOAuthClient {
|
|
58
60
|
id: number
|
|
59
|
-
ownerId: number
|
|
61
|
+
ownerId: number | null
|
|
60
62
|
name: string
|
|
61
63
|
type: 'confidential' | 'public'
|
|
62
64
|
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod
|
|
@@ -50,7 +50,7 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
50
50
|
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
51
51
|
personal_access_client, password_client, revoked
|
|
52
52
|
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1${lock}
|
|
53
|
-
`,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0]??"",client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
|
|
53
|
+
`,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");return persistOAuthClient(provider,ownerId,input)}export async function registerDynamicOAuthClient(provider,input){input=validateOAuthClientRegistration(provider,input);if(!provider.dynamicRegistration||input.grantTypes.some((grant)=>!["authorization_code","refresh_token"].includes(grant))||!input.redirectUris.length||input.redirectUris.some((uri)=>!provider.dynamicRegistration.allowedRedirectOrigins.includes(new URL(uri).origin)))throw new OAuthClientRegistrationError("Dynamic client registration is not allowed for this client.");return persistOAuthClient(provider,null,input)}async function persistOAuthClient(provider,ownerId,input){const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0]??"",client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
|
|
54
54
|
INSERT INTO oauth_clients (
|
|
55
55
|
user_id, name, secret, provider, redirect, client_type, redirect_uris, grant_types,
|
|
56
56
|
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export declare interface ApproveOAuthAuthorizationRequestSessionInput {
|
|
|
22
22
|
workspaceId?: string | null
|
|
23
23
|
resolveWorkspaceId?: () => Promise<string | null>
|
|
24
24
|
authorizationCodeLifetimeMs: number
|
|
25
|
+
approvedScopes?: readonly string[]
|
|
25
26
|
}
|
|
26
27
|
export declare interface OAuthAuthorizationConsentResult {
|
|
27
28
|
code: string
|
|
@@ -75,6 +76,7 @@ export declare interface OAuthAuthorizationConsentView {
|
|
|
75
76
|
export declare interface OAuthAuthorizationConsentRequest {
|
|
76
77
|
requestId: string
|
|
77
78
|
decision: 'approve' | 'deny'
|
|
79
|
+
approvedScopes?: string[]
|
|
78
80
|
}
|
|
79
81
|
export declare class OAuthAuthorizationConsentRequestError extends Error {
|
|
80
82
|
readonly code: 'invalid_request';
|
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";async function assertOAuthSubjectEligibility(provider,request,subjectType,subjectId,grantId,workspaceId){if(provider.subjectEligibility&&!await provider.subjectEligibility({type:subjectType,id:subjectId,clientId:Number(request.clientId),grantId,workspaceId}))throw new OAuthAuthorizationRequestError("invalid_request","OAuth subject is not eligible.",request.redirectUri,request.state)}export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}class NoReusableOAuthConsentError extends Error{}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");return{requestId,decision}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}async function reusableOAuthConsentGrantId(input,options={}){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return null;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return null;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return null;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`,lock=options.lock?sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"":"",rows=await db.primary.unsafe(`
|
|
1
|
+
import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{db,getDatabaseDialect,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{issueAuthorizationCode}from"./oauth-authorization-codes";import{isOAuthAuthorizationRequestId,loadOAuthAuthorizationRequestSession,withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient,loadOAuthAuthorizationClientDetails}from"./oauth-client-registration";import{createOAuthGrant}from"./oauth-grants";async function assertOAuthSubjectEligibility(provider,request,subjectType,subjectId,grantId,workspaceId){if(provider.subjectEligibility&&!await provider.subjectEligibility({type:subjectType,id:subjectId,clientId:Number(request.clientId),grantId,workspaceId}))throw new OAuthAuthorizationRequestError("invalid_request","OAuth subject is not eligible.",request.redirectUri,request.state)}export class OAuthAuthorizationConsentRequestError extends Error{code="invalid_request";constructor(message){super(message);this.name="OAuthAuthorizationConsentRequestError"}}class NoReusableOAuthConsentError extends Error{}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}function includesAll(approved,requested){return requested.every((value)=>approved.includes(value))}function includesResourceBindings(approvedResources,approvedAudiences,requestedResources,requestedAudiences){if(approvedResources.length!==approvedAudiences.length||requestedResources.length!==requestedAudiences.length)return!1;return requestedResources.every((resource,index)=>{const approvedIndex=approvedResources.indexOf(resource);return approvedIndex>=0&&approvedAudiences[approvedIndex]===requestedAudiences[index]})}function consentParameter(params,name){const values=params.getAll(name);if(values.length!==1||values[0]==="")throw new OAuthAuthorizationConsentRequestError(`OAuth consent parameter must appear exactly once: ${name}`);return values[0]}export function parseOAuthAuthorizationConsentRequest(body){if((typeof body==="string"?body:body.toString()).length>4096)throw new OAuthAuthorizationConsentRequestError("OAuth consent request is too large.");const params=typeof body==="string"?new URLSearchParams(body):body,requestId=consentParameter(params,"request_id"),decision=consentParameter(params,"decision");if(!isOAuthAuthorizationRequestId(requestId))throw new OAuthAuthorizationConsentRequestError("OAuth consent request identifier is invalid.");if(decision!=="approve"&&decision!=="deny")throw new OAuthAuthorizationConsentRequestError("OAuth consent decision is invalid.");const scopeValues=params.getAll("scope");if(scopeValues.length>1)throw new OAuthAuthorizationConsentRequestError("Consent scope must appear once.");const approvedScopes=scopeValues.length?scopeValues[0].split(" "):void 0;if(approvedScopes&&(!approvedScopes.length||approvedScopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(approvedScopes).size!==approvedScopes.length))throw new OAuthAuthorizationConsentRequestError("Invalid consent scopes.");return{requestId,decision,...approvedScopes?{approvedScopes}:{}}}export async function loadOAuthAuthorizationConsentView(input){const request=await loadOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId);if(!request)return null;const client=await loadOAuthAuthorizationClientDetails(request.clientId);if(!client)return null;let current;try{current=validateOAuthAuthorizationRequest(input.provider,client,{responseType:request.responseType,clientId:request.clientId,redirectUri:request.redirectUri,scope:request.scopes.join(" "),resource:request.audiences,...request.state===null?{}:{state:request.state},codeChallenge:request.codeChallenge,codeChallengeMethod:request.codeChallengeMethod})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return null;throw error}const permissions=[];for(const name of current.scopes){const scope=input.provider.scopes[name];if(!scope)return null;permissions.push({name,description:scope.description})}const resources=[];for(const[index,name]of current.resources.entries()){const resource=input.provider.resources[name],audience=current.audiences[index];if(!resource||!audience)return null;resources.push({name,audience,...resource.description===void 0?{}:{description:resource.description}})}return{requestId:input.requestId,client:{id:current.clientId,name:client.name,type:current.clientType},permissions,resources}}async function reusableOAuthConsentGrantId(input,options={}){if(!Number.isFinite(input.rememberForMs)||input.rememberForMs<=0||!Number.isSafeInteger(input.subjectId)||input.subjectId<=0||!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType)||input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))return null;const clientId=Number(input.request.clientId),cutoff=new Date(Date.now()-input.rememberForMs);if(!Number.isSafeInteger(clientId)||clientId<=0||!Number.isFinite(cutoff.getTime()))return null;const client=await loadOAuthAuthorizationClient(input.request.clientId);if(!client||client.revoked||client.type!==input.request.clientType||!client.redirectUris.includes(input.request.redirectUri)||!client.grantTypes.includes("authorization_code")||!includesAll(client.scopes,input.request.scopes)||!includesAll(client.resources,input.request.resources))return null;const sql=sqlHelpers(getDatabaseDialect()),params=[clientId,input.subjectType,input.subjectId,sqlDateTime(cutoff)],workspace=input.workspaceId??null,workspacePredicate=workspace===null?"g.workspace_id IS NULL":`g.workspace_id = ${sql.param(params.push(workspace))}`,lock=options.lock?sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"":"",rows=await db.primary.unsafe(`
|
|
2
2
|
SELECT g.id, g.scopes, g.resources, g.audiences, g.created_at
|
|
3
3
|
FROM oauth_grants g
|
|
4
4
|
JOIN oauth_clients c ON c.id = g.client_id AND c.revoked = ${sql.boolFalse}
|
|
@@ -9,4 +9,4 @@ import{assertOAuthAuthorizationProviderPolicy,OAuthAuthorizationRequestError,val
|
|
|
9
9
|
AND g.created_at >= ${sql.param(4)}
|
|
10
10
|
AND ${workspacePredicate}
|
|
11
11
|
ORDER BY g.created_at DESC, g.id DESC${lock}
|
|
12
|
-
`,params);for(const row of rows){const grantId=String(row.id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at)?.getTime()??0;if(!(/^[a-f0-9]{32}$/.test(grantId)&&createdAt>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)))continue;if(options.exact&&(JSON.stringify(scopes)!==JSON.stringify(input.request.scopes)||JSON.stringify(resources)!==JSON.stringify(input.request.resources)||JSON.stringify(audiences)!==JSON.stringify(input.request.audiences)))continue;return grantId}return null}export async function hasReusableOAuthConsent(input){return await reusableOAuthConsentGrantId(input)!==null}export async function reuseOAuthAuthorizationRequestSession(input){if(input.provider.consent.rememberFor<=0)return null;try{return await withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new NoReusableOAuthConsentError;const grantId=await reusableOAuthConsentGrantId({request,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId,rememberForMs:input.provider.consent.rememberFor},{exact:!0,lock:!0});if(!grantId)throw new NoReusableOAuthConsentError;await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grantId,workspaceId??null);return{code:await issueAuthorizationCode({grantId,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.provider.lifetimes.authorizationCode}),grantId,redirectUri:request.redirectUri,state:request.state}})}catch(error){if(error instanceof NoReusableOAuthConsentError)return null;throw error}}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new OAuthAuthorizationRequestError("invalid_request","OAuth consent workspace changed after it was displayed.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:
|
|
12
|
+
`,params);for(const row of rows){const grantId=String(row.id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at)?.getTime()??0;if(!(/^[a-f0-9]{32}$/.test(grantId)&&createdAt>=cutoff.getTime()&&scopes!==null&&resources!==null&&audiences!==null&&includesAll(scopes,input.request.scopes)&&includesResourceBindings(resources,audiences,input.request.resources,input.request.audiences)))continue;if(options.exact&&(JSON.stringify(scopes)!==JSON.stringify(input.request.scopes)||JSON.stringify(resources)!==JSON.stringify(input.request.resources)||JSON.stringify(audiences)!==JSON.stringify(input.request.audiences)))continue;return grantId}return null}export async function hasReusableOAuthConsent(input){return await reusableOAuthConsentGrantId(input)!==null}export async function reuseOAuthAuthorizationRequestSession(input){if(input.provider.consent.rememberFor<=0)return null;try{return await withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new NoReusableOAuthConsentError;const grantId=await reusableOAuthConsentGrantId({request,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId,rememberForMs:input.provider.consent.rememberFor},{exact:!0,lock:!0});if(!grantId)throw new NoReusableOAuthConsentError;await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grantId,workspaceId??null);return{code:await issueAuthorizationCode({grantId,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.provider.lifetimes.authorizationCode}),grantId,redirectUri:request.redirectUri,state:request.state}})}catch(error){if(error instanceof NoReusableOAuthConsentError)return null;throw error}}export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request,workspace)=>{assertOAuthAuthorizationProviderPolicy(input.provider,request);const workspaceId=input.resolveWorkspaceId?await input.resolveWorkspaceId():input.workspaceId;if(input.provider.consent.resolveWorkspace&&(!workspace.bound||workspace.id!==(workspaceId??null)))throw new OAuthAuthorizationRequestError("invalid_request","OAuth consent workspace changed after it was displayed.");const approvedScopes=input.approvedScopes?[...input.approvedScopes]:request.scopes;if(!approvedScopes.length||new Set(approvedScopes).size!==approvedScopes.length||approvedScopes.some((scope)=>!request.scopes.includes(scope)))throw new OAuthAuthorizationRequestError("invalid_scope","Consent may only grant a non-empty subset of requested scopes.");const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:approvedScopes,resources:request.resources,audiences:request.audiences,workspaceId});await assertOAuthSubjectEligibility(input.provider,request,input.subjectType,input.subjectId,grant.id,workspaceId??null);return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** RFC 7591 metadata for an explicitly enabled authorization-code provider. No remote URLs are fetched. */
|
|
3
|
+
export declare function parseDynamicOAuthRegistration(provider: ResolvedOAuthProviderConfig, body: unknown): unknown;
|
|
4
|
+
/** The route must apply a bounded JSON read and an IP quota before calling this. */
|
|
5
|
+
export declare function handleDynamicOAuthRegistration(provider: ResolvedOAuthProviderConfig, body: unknown): Promise<Response>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{OAuthClientRegistrationError,registerDynamicOAuthClient,validateOAuthClientRegistration}from"./oauth-client-registration";export function parseDynamicOAuthRegistration(provider,body){if(!provider.dynamicRegistration||!body||typeof body!=="object"||Array.isArray(body))throw new OAuthClientRegistrationError("Dynamic registration is unavailable or the metadata is invalid.");const input=body,strings=(value,fallback)=>{if(value===void 0)return fallback;if(!Array.isArray(value)||value.length>10||!value.every((item)=>typeof item==="string"&&item.length<=2048))throw new OAuthClientRegistrationError("Invalid client metadata array.");return value},method=input.token_endpoint_auth_method??"client_secret_basic";if(method!=="none"&&method!=="client_secret_basic")throw new OAuthClientRegistrationError("Unsupported token endpoint authentication method.");const redirectUris=strings(input.redirect_uris,[]);if(!redirectUris.length||redirectUris.some((uri)=>{try{return!provider.dynamicRegistration.allowedRedirectOrigins.includes(new URL(uri).origin)}catch{return!0}}))throw new OAuthClientRegistrationError("Redirect origin is not allowed.");const responseTypes=strings(input.response_types,["code"]),grantTypes=strings(input.grant_types,["authorization_code"]);if(responseTypes.length!==1||responseTypes[0]!=="code"||grantTypes.some((grant)=>!["authorization_code","refresh_token"].includes(grant)))throw new OAuthClientRegistrationError("Only the authorization-code profile is supported.");if(input.scope!==void 0&&(typeof input.scope!=="string"||input.scope.length>4096))throw new OAuthClientRegistrationError("Invalid scope metadata.");if(input.client_name!==void 0&&typeof input.client_name!=="string")throw new OAuthClientRegistrationError("Invalid client name.");const scopes=typeof input.scope==="string"?input.scope.split(" "):Object.keys(provider.scopes);return validateOAuthClientRegistration(provider,{name:typeof input.client_name==="string"?input.client_name:"MCP client",type:method==="none"?"public":"confidential",tokenEndpointAuthMethod:method,redirectUris,grantTypes,scopes,resources:Object.keys(provider.resources)})}export async function handleDynamicOAuthRegistration(provider,body){const headers={"Content-Type":"application/json; charset=utf-8","Cache-Control":"no-store",Pragma:"no-cache"};if(!provider.dynamicRegistration)return new Response(null,{status:404,headers});try{const input=parseDynamicOAuthRegistration(provider,body),result=await registerDynamicOAuthClient(provider,input);return Response.json({client_id:String(result.client.id),client_id_issued_at:Math.floor(result.client.createdAt.getTime()/1000),client_name:result.client.name,redirect_uris:result.client.redirectUris,token_endpoint_auth_method:result.client.tokenEndpointAuthMethod,grant_types:result.client.grantTypes,response_types:["code"],scope:result.client.scopes.join(" "),...result.plainTextSecret?{client_secret:result.plainTextSecret,client_secret_expires_at:0}:{}},{status:201,headers})}catch(error){if(!(error instanceof OAuthClientRegistrationError))throw error;return Response.json({error:"invalid_client_metadata",error_description:error.message},{status:400,headers})}}
|
package/dist/oauth-metadata.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare function oauthAuthorizationServerMetadataPath(issuer: string): st
|
|
|
4
4
|
/** Build RFC 8414 metadata from the provider profile Stacks actually serves. */
|
|
5
5
|
export declare function oauthAuthorizationServerMetadata(provider: ResolvedOAuthProviderConfig): OAuthAuthorizationServerMetadata;
|
|
6
6
|
export declare interface OAuthAuthorizationServerMetadata {
|
|
7
|
+
registration_endpoint?: string
|
|
8
|
+
authorization_response_iss_parameter_supported: true
|
|
7
9
|
issuer: string
|
|
8
10
|
authorization_endpoint: string
|
|
9
11
|
token_endpoint: string
|
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),metadata={issuer:provider.issuer,authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()};if(provider.introspection)metadata.introspection_endpoint=provider.endpoints.introspection;return metadata}
|
|
1
|
+
function clientAuthenticationMethods(provider){const methods=[];if(provider.clientTypes.includes("confidential"))methods.push("client_secret_basic");if(provider.clientTypes.includes("public"))methods.push("none");return methods}export function oauthAuthorizationServerMetadataPath(issuer){const issuerPath=new URL(issuer).pathname.replace(/^\/+|\/+$/g,"");return`/.well-known/oauth-authorization-server${issuerPath?`/${issuerPath}`:""}`}export function oauthAuthorizationServerMetadata(provider){const clientMethods=clientAuthenticationMethods(provider),metadata={issuer:provider.issuer,authorization_response_iss_parameter_supported:!0,...provider.dynamicRegistration?{registration_endpoint:provider.dynamicRegistration.endpoint}:{},authorization_endpoint:provider.endpoints.authorization,token_endpoint:provider.endpoints.token,revocation_endpoint:provider.endpoints.revocation,response_types_supported:[...provider.responseTypes],grant_types_supported:[...provider.grantTypes],code_challenge_methods_supported:[...provider.codeChallengeMethods],token_endpoint_auth_methods_supported:[...clientMethods],revocation_endpoint_auth_methods_supported:[...clientMethods],scopes_supported:Object.keys(provider.scopes).sort()};if(provider.introspection)metadata.introspection_endpoint=provider.endpoints.introspection;return metadata}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** RFC 9728 metadata for the exact protected audience, never the inbound Host. */
|
|
2
|
+
export declare function oauthProtectedResourceMetadata(options: OAuthProtectedResourceOptions): {
|
|
3
|
+
resource: OAuthProtectedResourceOptions.resource;
|
|
4
|
+
authorization_servers: (OAuthProtectedResourceOptions.authorizationServers)[number][];
|
|
5
|
+
bearer_methods_supported: string[]
|
|
6
|
+
};
|
|
7
|
+
export declare function oauthProtectedResourceMetadataPath(resource: string): string;
|
|
8
|
+
export declare interface OAuthProtectedResourceOptions {
|
|
9
|
+
resource: string
|
|
10
|
+
authorizationServers: readonly string[]
|
|
11
|
+
scopesSupported?: readonly string[]
|
|
12
|
+
documentation?: string
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function oauthProtectedResourceMetadata(options){for(const value of[options.resource,...options.authorizationServers,...options.documentation?[options.documentation]:[]]){const url=new URL(value),loopback=["localhost","127.0.0.1","[::1]"].includes(url.hostname);if(url.protocol!=="https:"&&!(loopback&&url.protocol==="http:")||url.username||url.password||url.hash||url.search)throw TypeError("OAuth resource metadata requires canonical HTTPS URLs, or HTTP loopback URLs.")}if(!options.authorizationServers.length)throw TypeError("An OAuth resource needs an authorization server.");return{resource:options.resource,authorization_servers:[...options.authorizationServers],bearer_methods_supported:["header"],...options.scopesSupported?{scopes_supported:[...options.scopesSupported]}:{},...options.documentation?{resource_documentation:options.documentation}:{}}}export function oauthProtectedResourceMetadataPath(resource){return`/.well-known/oauth-protected-resource${new URL(resource).pathname.replace(/\/$/,"")}`}
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OAuthProviderConsentWorkspace, OAuthProviderConsentWorkspaceContext, OAuthProviderConsentWorkspaceResolver, OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig, OAuthProviderSubjectEligibility } from '@stacksjs/types';
|
|
2
|
+
declare function endpoint(issuer: URL, configured: string | undefined, fallback: string): string;
|
|
2
3
|
/** Resolve and validate application-owned workspace authority for consent. */
|
|
3
4
|
export declare function resolveOAuthConsentWorkspace(provider: ResolvedOAuthProviderConfig, context: OAuthProviderConsentWorkspaceContext): Promise<OAuthProviderConsentWorkspace | null>;
|
|
4
5
|
/**
|
|
@@ -20,6 +21,7 @@ export declare interface ResolvedOAuthProviderConfig {
|
|
|
20
21
|
revocation: string
|
|
21
22
|
introspection: string
|
|
22
23
|
}
|
|
24
|
+
dynamicRegistration?: { endpoint: string, allowedRedirectOrigins: readonly string[] }
|
|
23
25
|
responseTypes: readonly ['code']
|
|
24
26
|
grantTypes: readonly ('authorization_code' | 'refresh_token' | 'client_credentials')[]
|
|
25
27
|
clientCredentials: boolean
|
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",introspection:"oauth/introspect"},RESERVED_AUTH_GET_PATHS=new Set(["/generate-registration-options","/generate-authentication-options"]),RESERVED_AUTH_POST_PATHS=new Set(["/login","/register","/verify-registration","/verify-authentication","/generate-two-factor-secret","/enable-two-factor","/disable-two-factor","/verify-two-factor-login"]);function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function assertEndpointRouteAvailable(name,value,methods){const pathname=new URL(value).pathname,reservedNamespace=pathname==="/auth"||pathname.startsWith("/auth/"),reservedMethod=methods.some((method)=>method==="GET"?RESERVED_AUTH_GET_PATHS.has(pathname):RESERVED_AUTH_POST_PATHS.has(pathname));if(reservedNamespace||reservedMethod)throw Error(`auth.oauthProvider ${name} endpoint must not shadow a reserved auth route: ${pathname}`)}function positiveLifetime(name,value){if(!Number.isSafeInteger(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive safe integer of milliseconds.`);if(!Number.isFinite(new Date(Date.now()+value).getTime()))throw Error(`auth.oauthProvider.lifetimes.${name} is outside the supported date range.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isSafeInteger(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative safe integer of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");const resolveWorkspace=options?.resolveWorkspace;if(resolveWorkspace!==void 0&&typeof resolveWorkspace!=="function")throw Error("auth.oauthProvider.consent.resolveWorkspace must be a function.");return{rememberFor,view,...resolveWorkspace?{resolveWorkspace}:{}}}export async function resolveOAuthConsentWorkspace(provider,context){const resolved=await provider.consent.resolveWorkspace?.(context)??null;if(resolved===null)return null;if(typeof resolved.id!=="string"||!resolved.id||resolved.id.length>255||/[\u0000-\u001F\u007F]/.test(resolved.id))throw Error("OAuth consent workspace id must contain 1 to 255 characters without controls.");if(typeof resolved.label!=="string"||!resolved.label.trim()||resolved.label.length>255||/[\u0000-\u001F\u007F]/.test(resolved.label))throw Error("OAuth consent workspace label must contain 1 to 255 characters without controls.");return{id:resolved.id,label:resolved.label.trim()}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),subjectEligibility=options.subjectEligibility;if(subjectEligibility!==void 0&&typeof subjectEligibility!=="function")throw Error("auth.oauthProvider.subjectEligibility must be a function.");const resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)},endpoints={authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)};if(new Set(Object.values(endpoints)).size!==Object.keys(endpoints).length)throw Error("auth.oauthProvider endpoints must each use a unique URL.");assertEndpointRouteAvailable("authorization",endpoints.authorization,["GET","POST"]);assertEndpointRouteAvailable("token",endpoints.token,["POST"]);assertEndpointRouteAvailable("revocation",endpoints.revocation,["POST"]);assertEndpointRouteAvailable("introspection",endpoints.introspection,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");const grantTypes=options.clientCredentials?["authorization_code","refresh_token","client_credentials"]:["authorization_code","refresh_token"];return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,responseTypes:["code"],grantTypes,clientCredentials:options.clientCredentials===!0,introspection:options.introspection===!0,...subjectEligibility?{subjectEligibility}:{},codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
|
|
1
|
+
import{oauthAuthorizationServerMetadataPath}from"./oauth-metadata";const DEFAULT_LIFETIMES={authorizationRequest:600000,authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"},RESERVED_AUTH_GET_PATHS=new Set(["/generate-registration-options","/generate-authentication-options"]),RESERVED_AUTH_POST_PATHS=new Set(["/login","/register","/verify-registration","/verify-authentication","/generate-two-factor-secret","/enable-two-factor","/disable-two-factor","/verify-two-factor-login"]);function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function assertEndpointRouteAvailable(name,value,methods){const pathname=new URL(value).pathname,reservedNamespace=pathname==="/auth"||pathname.startsWith("/auth/"),reservedMethod=methods.some((method)=>method==="GET"?RESERVED_AUTH_GET_PATHS.has(pathname):RESERVED_AUTH_POST_PATHS.has(pathname));if(reservedNamespace||reservedMethod)throw Error(`auth.oauthProvider ${name} endpoint must not shadow a reserved auth route: ${pathname}`)}function positiveLifetime(name,value){if(!Number.isSafeInteger(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive safe integer of milliseconds.`);if(!Number.isFinite(new Date(Date.now()+value).getTime()))throw Error(`auth.oauthProvider.lifetimes.${name} is outside the supported date range.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isSafeInteger(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative safe integer of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");const resolveWorkspace=options?.resolveWorkspace;if(resolveWorkspace!==void 0&&typeof resolveWorkspace!=="function")throw Error("auth.oauthProvider.consent.resolveWorkspace must be a function.");return{rememberFor,view,...resolveWorkspace?{resolveWorkspace}:{}}}export async function resolveOAuthConsentWorkspace(provider,context){const resolved=await provider.consent.resolveWorkspace?.(context)??null;if(resolved===null)return null;if(typeof resolved.id!=="string"||!resolved.id||resolved.id.length>255||/[\u0000-\u001F\u007F]/.test(resolved.id))throw Error("OAuth consent workspace id must contain 1 to 255 characters without controls.");if(typeof resolved.label!=="string"||!resolved.label.trim()||resolved.label.length>255||/[\u0000-\u001F\u007F]/.test(resolved.label))throw Error("OAuth consent workspace label must contain 1 to 255 characters without controls.");return{id:resolved.id,label:resolved.label.trim()}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),subjectEligibility=options.subjectEligibility;if(subjectEligibility!==void 0&&typeof subjectEligibility!=="function")throw Error("auth.oauthProvider.subjectEligibility must be a function.");const resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationRequest:positiveLifetime("authorizationRequest",options.lifetimes?.authorizationRequest??DEFAULT_LIFETIMES.authorizationRequest),authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)},endpoints={authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)};if(new Set(Object.values(endpoints)).size!==Object.keys(endpoints).length)throw Error("auth.oauthProvider endpoints must each use a unique URL.");assertEndpointRouteAvailable("authorization",endpoints.authorization,["GET","POST"]);assertEndpointRouteAvailable("token",endpoints.token,["POST"]);assertEndpointRouteAvailable("revocation",endpoints.revocation,["POST"]);assertEndpointRouteAvailable("introspection",endpoints.introspection,["POST"]);if(new URL(endpoints.authorization).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("auth.oauthProvider authorization endpoint must not shadow the metadata route.");let dynamicRegistration;if(options.dynamicRegistration){const origins=[...options.dynamicRegistration.allowedRedirectOrigins];if(!origins.length||origins.some((origin)=>new URL(origin).origin!==origin||!origin.startsWith("https://")))throw Error("OAuth dynamic registration requires exact HTTPS redirect origins.");const registration=endpoint(issuer,options.dynamicRegistration.endpoint,"oauth/register");if(Object.values(endpoints).includes(registration)||new URL(registration).pathname===oauthAuthorizationServerMetadataPath(issuer.toString()))throw Error("OAuth registration endpoint must use a unique URL.");assertEndpointRouteAvailable("registration",registration,["POST"]);if(!resolvedClientTypes(options.clientTypes).includes("public"))throw Error("OAuth dynamic registration requires public clients.");dynamicRegistration={endpoint:registration,allowedRedirectOrigins:origins}}const grantTypes=options.clientCredentials?["authorization_code","refresh_token","client_credentials"]:["authorization_code","refresh_token"];return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints,...dynamicRegistration?{dynamicRegistration}:{},responseTypes:["code"],grantTypes,clientCredentials:options.clientCredentials===!0,introspection:options.introspection===!0,...subjectEligibility?{subjectEligibility}:{},codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OAuthAuthorizationPageSubject } from './oauth-authorization-endpoint';
|
|
2
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
3
|
+
/**
|
|
4
|
+
* Framework-owned OAuth HTTP boundaries for a custom application consent UI.
|
|
5
|
+
* Mount raw-body protocol handlers with parseBody(false) and protocol CSRF exemptions.
|
|
6
|
+
* Consent and connection handlers require ordinary login and router CSRF protection.
|
|
7
|
+
* The application supplies its domain eligibility policy and authenticated subject.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createOAuthAuthorizationServer(options: OAuthAuthorizationServerOptions): OAuthAuthorizationServer;
|
|
10
|
+
export declare interface OAuthAuthorizationServerOptions {
|
|
11
|
+
provider: ResolvedOAuthProviderConfig | (() => ResolvedOAuthProviderConfig)
|
|
12
|
+
consentPage: string
|
|
13
|
+
authenticate: (request: Request) => Promise<(OAuthAuthorizationPageSubject & { label: string }) | Response | null>
|
|
14
|
+
connectionAudience?: string
|
|
15
|
+
}
|
|
16
|
+
/** HTTP response contracts remain concrete in published declaration files. */
|
|
17
|
+
export declare interface OAuthAuthorizationServer {
|
|
18
|
+
metadata: () => Response
|
|
19
|
+
register: (request: Request) => Promise<Response>
|
|
20
|
+
begin: (request: Request) => Promise<Response>
|
|
21
|
+
consent: (request: Request) => Promise<Response>
|
|
22
|
+
token: (request: Request) => Promise<Response>
|
|
23
|
+
revoke: (request: Request) => Promise<Response>
|
|
24
|
+
connections: (request: Request) => Promise<Response>
|
|
25
|
+
disconnect: (request: Request, grantId: string) => Promise<Response>
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readJsonBody,readTextBody,RequestBodyError}from"@stacksjs/router/body";import{OAuthAuthorizationRequestError}from"./oauth-authorization";import{beginOAuthAuthorizationRequest,handleOAuthAuthorizationConsentRequest}from"./oauth-authorization-endpoint";import{bindOAuthAuthorizationRequestWorkspace}from"./oauth-authorization-requests";import{oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{loadOAuthAuthorizationConsentView}from"./oauth-consent";import{handleDynamicOAuthRegistration}from"./oauth-dynamic-registration";import{disconnectOAuthGrant,listOAuthConnections}from"./oauth-grants";import{oauthAuthorizationServerMetadata}from"./oauth-metadata";import{handleOAuthRevocationRequest}from"./oauth-revocation";import{handleOAuthTokenRequest}from"./oauth-token-endpoint";function json(body,status=200){return Response.json(body,{status,headers:{"Cache-Control":"no-store",Pragma:"no-cache","Referrer-Policy":"no-referrer"}})}const invalid=()=>json({error:"invalid_request"},400);export function createOAuthAuthorizationServer(options){if(!/^\/(?!\/)[^?#\\]*$/.test(options.consentPage)||/[\u0000-\u0020]/.test(options.consentPage))throw TypeError("OAuth consentPage must be a local absolute path.");const provider=()=>typeof options.provider==="function"?options.provider():options.provider,identity=async(request)=>{const subject=await options.authenticate(request);if(subject instanceof Response)return subject;if(!subject||!/^[A-Za-z0-9_.:-]{1,255}$/.test(subject.type)||!Number.isSafeInteger(subject.id)||subject.id<=0||!subject.label.trim())return json({error:"authentication_required"},401);return subject},method=(request,expected)=>request.method!==expected?new Response(null,{status:405,headers:{Allow:expected,"Cache-Control":"no-store"}}):null,wire=async(request,handler)=>{const denied=method(request,"POST");if(denied)return denied;try{return await handler(await readTextBody(request,16384))}catch(error){if(error instanceof RequestBodyError)return json({error:"invalid_request"},error.status);throw error}};return{metadata:()=>json(oauthAuthorizationServerMetadata(provider())),async register(request){const denied=method(request,"POST");if(denied)return denied;try{return await handleDynamicOAuthRegistration(provider(),await readJsonBody(request,16384))}catch(error){if(error instanceof RequestBodyError)return json({error:"invalid_client_metadata"},error.status);throw error}},async begin(request){const denied=method(request,"GET");if(denied)return denied;const resolved=provider(),browser=oauthAuthorizationBrowserSession(request,resolved);try{const result=await beginOAuthAuthorizationRequest({provider:resolved,query:new URL(request.url).searchParams,browserSessionId:browser.id});return new Response(null,{status:302,headers:{Location:`${options.consentPage}?request_id=${encodeURIComponent(result.requestId)}`,"Set-Cookie":browser.cookie,"Cache-Control":"no-store","Referrer-Policy":"no-referrer"}})}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;return oauthAuthorizationRequestErrorResponse(error,resolved.issuer)??invalid()}},async consent(request){if(!["GET","POST"].includes(request.method))return method(request,"GET, POST");const subject=await identity(request);if(subject instanceof Response)return subject;const resolved=provider();if(request.method==="POST")return handleOAuthAuthorizationConsentRequest({provider:resolved,request,subjectType:subject.type,subjectId:subject.id,workspaceId:subject.workspaceId,resolveWorkspaceId:subject.resolveWorkspaceId});const params=new URL(request.url).searchParams;if(params.getAll("request_id").length!==1||[...params.keys()].some((key)=>key!=="request_id"))return invalid();if(resolved.consent.resolveWorkspace&&!await bindOAuthAuthorizationRequestWorkspace(params.get("request_id"),oauthAuthorizationBrowserSession(request,resolved).id,subject.workspaceId??null))return invalid();const consent=await loadOAuthAuthorizationConsentView({provider:resolved,requestId:params.get("request_id"),browserSessionId:oauthAuthorizationBrowserSession(request,resolved).id});if(!consent)return invalid();return json({consent,user:{id:subject.id,name:subject.label}})},token:(request)=>wire(request,(body)=>{const resolved=provider();return handleOAuthTokenRequest(resolved,{body,contentType:request.headers.get("content-type"),authorization:request.headers.get("authorization")},{isSubjectEligible:resolved.subjectEligibility??(()=>!1)})}),revoke:(request)=>wire(request,(body)=>handleOAuthRevocationRequest({body,contentType:request.headers.get("content-type"),authorization:request.headers.get("authorization")})),async connections(request){const denied=method(request,"GET");if(denied)return denied;const subject=await identity(request);if(subject instanceof Response)return subject;const connections=(await listOAuthConnections(subject.type,subject.id)).filter((connection)=>!options.connectionAudience||connection.audiences.includes(options.connectionAudience));return json({connections})},async disconnect(request,grantId){const denied=method(request,"DELETE");if(denied)return denied;const subject=await identity(request);if(subject instanceof Response)return subject;if(!(await listOAuthConnections(subject.type,subject.id)).some((connection)=>connection.grantId===grantId&&(!options.connectionAudience||connection.audiences.includes(options.connectionAudience))))return json({disconnected:!1});return json({disconnected:await disconnectOAuthGrant(subject.type,subject.id,grantId)})}}}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{exchangeOAuthClientCredentials,exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input,dependencies){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}if(request.grantType==="client_credentials"){const result=await exchangeOAuthClientCredentials({provider,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
|
1
|
+
import{exchangeOAuthClientCredentials,exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input,dependencies){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,audiences:request.audiences,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}if(request.grantType==="client_credentials"){const result=await exchangeOAuthClientCredentials({provider,audiences:request.audiences,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,audiences:request.audiences,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken,isSubjectEligible:dependencies.isSubjectEligible});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
|
@@ -33,6 +33,7 @@ export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAut
|
|
|
33
33
|
}
|
|
34
34
|
export declare interface RefreshOAuthDelegatedTokenInput {
|
|
35
35
|
refreshToken: string
|
|
36
|
+
audiences?: readonly string[]
|
|
36
37
|
clientId: number
|
|
37
38
|
clientSecret?: string
|
|
38
39
|
scopes?: readonly string[]
|
|
@@ -41,6 +42,7 @@ export declare interface RefreshOAuthDelegatedTokenInput {
|
|
|
41
42
|
isSubjectEligible?: OAuthSubjectEligibility
|
|
42
43
|
}
|
|
43
44
|
export declare interface ExchangeOAuthClientCredentialsInput {
|
|
45
|
+
audiences?: readonly string[]
|
|
44
46
|
provider: ResolvedOAuthProviderConfig
|
|
45
47
|
clientId: number
|
|
46
48
|
clientSecret?: string
|
|
@@ -33,7 +33,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
33
33
|
SELECT id FROM oauth_clients
|
|
34
34
|
WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
|
|
35
35
|
LIMIT 1${clientLock}
|
|
36
|
-
`,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return inactiveSubject}return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})});if(!result.ok)return result;return result.value===inactiveSubject?invalidGrant:{ok:!0,value:result.value}}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>{const exchanged=await exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")});if(!exchanged.ok)await containAuthorizationCodeReplay(exchange);return exchanged})??invalidClient}export async function exchangeOAuthClientCredentials(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!input.provider.clientCredentials||client.type!=="confidential"||!client.grantTypes.includes("client_credentials"))return{result:unauthorizedClient,wrote:!1};const scopes=input.scopes?[...input.scopes]:[...client.scopes];if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>client.scopes.includes(scope)&&input.provider.scopes[scope]))return{result:invalidScope,wrote:!1};const resources=[...new Set(scopes.flatMap((scope)=>input.provider.scopes[scope]?.resources??[]))];if(!resources.every((resource)=>client.resources.includes(resource)&&input.provider.resources[resource]))return{result:invalidScope,wrote:!1};const machineClientId=Number(client.id),grant=await createOAuthGrant({clientId:machineClientId,subjectType:"oauth_clients",subjectId:machineClientId,scopes,resources,audiences:resources.map((resource)=>input.provider.resources[resource].audience)});return{result:{ok:!0,value:await mintDelegatedTokenPair({grantId:grant.id,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId},{accessLifetimeMs,refreshLifetimeMs:0,issueRefreshToken:!1})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
|
|
36
|
+
`,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return inactiveSubject}return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})});if(!result.ok)return result;return result.value===inactiveSubject?invalidGrant:{ok:!0,value:result.value}}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>{const exchanged=await exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")});if(!exchanged.ok)await containAuthorizationCodeReplay(exchange);return exchanged})??invalidClient}export async function exchangeOAuthClientCredentials(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!input.provider.clientCredentials||client.type!=="confidential"||!client.grantTypes.includes("client_credentials"))return{result:unauthorizedClient,wrote:!1};const scopes=input.scopes?[...input.scopes]:[...client.scopes];if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>client.scopes.includes(scope)&&input.provider.scopes[scope]))return{result:invalidScope,wrote:!1};const resources=[...new Set(scopes.flatMap((scope)=>input.provider.scopes[scope]?.resources??[]))];if(!resources.every((resource)=>client.resources.includes(resource)&&input.provider.resources[resource]))return{result:invalidScope,wrote:!1};const machineClientId=Number(client.id);if(input.audiences&&(!input.audiences.length||new Set(input.audiences).size!==input.audiences.length||input.audiences.length!==resources.length||input.audiences.some((audience)=>!resources.some((resource)=>input.provider.resources[resource]?.audience===audience))))return{result:invalidScope,wrote:!1};const grant=await createOAuthGrant({clientId:machineClientId,subjectType:"oauth_clients",subjectId:machineClientId,scopes,resources,audiences:resources.map((resource)=>input.provider.resources[resource].audience)});return{result:{ok:!0,value:await mintDelegatedTokenPair({grantId:grant.id,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId},{accessLifetimeMs,refreshLifetimeMs:0,issueRefreshToken:!1})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
|
|
37
37
|
SELECT r.id, r.family_id, a.oauth_client_id
|
|
38
38
|
FROM oauth_refresh_tokens r
|
|
39
39
|
JOIN oauth_access_tokens a ON a.id = r.access_token_id
|
|
@@ -60,7 +60,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
60
60
|
FROM oauth_grants
|
|
61
61
|
WHERE id = ${sql.param(1)}
|
|
62
62
|
LIMIT 1${grantLock}
|
|
63
|
-
`,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return{result:invalidGrant,wrote:!0}}const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
|
|
63
|
+
`,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if(input.isSubjectEligible&&!await input.isSubjectEligible({type:grant.subjectType,id:grant.subjectId,clientId:grant.clientId,grantId:grant.grantId,workspaceId:grant.workspaceId})){if(!await disconnectOAuthGrant(grant.subjectType,grant.subjectId,grant.grantId))throw Error("Inactive OAuth subject authorization state could not be revoked.");return{result:invalidGrant,wrote:!0}}if(input.audiences&&(!input.audiences.length||new Set(input.audiences).size!==input.audiences.length||input.audiences.length!==grant.audiences.length||input.audiences.some((audience)=>!grant.audiences.includes(audience))))return{result:invalidGrant,wrote:!1};const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
|
|
64
64
|
UPDATE oauth_refresh_tokens
|
|
65
65
|
SET revoked = ${sql.param(1)}
|
|
66
66
|
WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
|
|
@@ -5,11 +5,13 @@ export declare function parseOAuthRevocationRequest(body: string, authorization?
|
|
|
5
5
|
/** Parse one RFC 7662 introspection request using confidential Basic authentication. */
|
|
6
6
|
export declare function parseOAuthIntrospectionRequest(body: string, authorization?: string | null): OAuthIntrospectionRequest;
|
|
7
7
|
export declare interface OAuthClientCredentials {
|
|
8
|
+
audiences?: string[]
|
|
8
9
|
clientId: number
|
|
9
10
|
clientSecret?: string
|
|
10
11
|
}
|
|
11
12
|
export declare interface OAuthAuthorizationCodeTokenRequest {
|
|
12
13
|
grantType: 'authorization_code'
|
|
14
|
+
audiences?: string[]
|
|
13
15
|
clientId: number
|
|
14
16
|
clientSecret?: string
|
|
15
17
|
code: string
|
|
@@ -18,6 +20,7 @@ export declare interface OAuthAuthorizationCodeTokenRequest {
|
|
|
18
20
|
}
|
|
19
21
|
export declare interface OAuthRefreshTokenRequest {
|
|
20
22
|
grantType: 'refresh_token'
|
|
23
|
+
audiences?: string[]
|
|
21
24
|
clientId: number
|
|
22
25
|
clientSecret?: string
|
|
23
26
|
refreshToken: string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Buffer}from"node:buffer";export class OAuthTokenRequestError extends Error{error;constructor(error,message){super(message);this.error=error;this.name="OAuthTokenRequestError"}}function reject(error,message){throw new OAuthTokenRequestError(error,message)}function single(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth token parameter must not be repeated: ${name}`);const value=values[0];if(required&&!value)reject("invalid_request",`OAuth token parameter is required: ${name}`);if(value?.includes("\uFFFD"))reject("invalid_request",`OAuth token parameter is not valid UTF-8: ${name}`);return value}function clientIdentifier(value){if(!/^[0-9]{1,20}$/.test(value))reject("invalid_client","OAuth client identifier is invalid.");const id=Number(value);if(!Number.isSafeInteger(id)||id<=0)reject("invalid_client","OAuth client identifier is invalid.");return id}function decodeCredential(value){try{return decodeURIComponent(value.replace(/\+/g," "))}catch{return reject("invalid_client","OAuth client credentials are malformed.")}}function basicCredentials(header){const match=/^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);if(!match)reject("invalid_client","OAuth client authentication must use a valid Basic header.");const encoded=match[1];let bytes;try{bytes=Buffer.from(encoded,"base64")}catch{return reject("invalid_client","OAuth client credentials are malformed.")}if(bytes.toString("base64").replace(/=+$/,"")!==encoded.replace(/=+$/,""))reject("invalid_client","OAuth client credentials are malformed.");let decoded;try{decoded=new TextDecoder("utf-8",{fatal:!0}).decode(bytes)}catch{return reject("invalid_client","OAuth client credentials are not valid UTF-8.")}const separator=decoded.indexOf(":");if(separator<1)reject("invalid_client","OAuth client credentials are malformed.");const clientId=clientIdentifier(decodeCredential(decoded.slice(0,separator))),clientSecret=decodeCredential(decoded.slice(separator+1));if(!clientSecret||clientSecret.length>4096)reject("invalid_client","OAuth client secret is invalid.");return{clientId,clientSecret}}function clientCredentials(params,authorization){const bodyClientId=single(params,"client_id"),bodyClientSecret=single(params,"client_secret");if(authorization){if(bodyClientId!==void 0||bodyClientSecret!==void 0)reject("invalid_request","OAuth client authentication methods must not be combined.");return basicCredentials(authorization)}if(bodyClientSecret!==void 0)reject("invalid_request","OAuth client secrets must use Basic authentication.");return{clientId:clientIdentifier(bodyClientId??"")}}function tokenValue(params,name){const value=single(params,name,!0);if(value.length>4096||/\s/.test(value))reject("invalid_request",`OAuth token parameter is invalid: ${name}`);return value}function requestedScopes(params){const value=single(params,"scope");if(value===void 0)return;const scopes=value.split(" ");if(scopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(scopes).size!==scopes.length)reject("invalid_request","OAuth scope must contain unique scope tokens.");return scopes}export function parseOAuthTokenRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth token request is too large.");const params=new URLSearchParams(body);for(const name of["grant_type","client_id","client_secret","code","redirect_uri","code_verifier","refresh_token","scope"])single(params,name);const credentials=clientCredentials(params,authorization),grantType=single(params,"grant_type",!0);if(grantType==="authorization_code"){const codeVerifier=single(params,"code_verifier",!0);if(!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeVerifier))reject("invalid_request","OAuth PKCE verifier is invalid.");const redirectUri=single(params,"redirect_uri",!0);if(redirectUri.length>2048)reject("invalid_request","OAuth redirect URI is too large.");return{grantType,...credentials,code:tokenValue(params,"code"),redirectUri,codeVerifier}}if(grantType==="refresh_token"){const scopes=requestedScopes(params);return{grantType,...credentials,refreshToken:tokenValue(params,"refresh_token"),...scopes?{scopes}:{}}}if(grantType==="client_credentials"){if(!authorization||!credentials.clientSecret)reject("invalid_client","The client_credentials grant requires Basic client authentication.");const scopes=requestedScopes(params);return{grantType,...credentials,...scopes?{scopes}:{}}}return reject("unsupported_grant_type","OAuth grant type is not supported.")}export function parseOAuthRevocationRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth revocation request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth revocation token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}export function parseOAuthIntrospectionRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth introspection request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);if(!authorization)reject("invalid_client","OAuth introspection requires Basic client authentication.");const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth introspection token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}
|
|
1
|
+
import{Buffer}from"node:buffer";export class OAuthTokenRequestError extends Error{error;constructor(error,message){super(message);this.error=error;this.name="OAuthTokenRequestError"}}function reject(error,message){throw new OAuthTokenRequestError(error,message)}function single(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth token parameter must not be repeated: ${name}`);const value=values[0];if(required&&!value)reject("invalid_request",`OAuth token parameter is required: ${name}`);if(value?.includes("\uFFFD"))reject("invalid_request",`OAuth token parameter is not valid UTF-8: ${name}`);return value}function clientIdentifier(value){if(!/^[0-9]{1,20}$/.test(value))reject("invalid_client","OAuth client identifier is invalid.");const id=Number(value);if(!Number.isSafeInteger(id)||id<=0)reject("invalid_client","OAuth client identifier is invalid.");return id}function decodeCredential(value){try{return decodeURIComponent(value.replace(/\+/g," "))}catch{return reject("invalid_client","OAuth client credentials are malformed.")}}function basicCredentials(header){const match=/^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(header);if(!match)reject("invalid_client","OAuth client authentication must use a valid Basic header.");const encoded=match[1];let bytes;try{bytes=Buffer.from(encoded,"base64")}catch{return reject("invalid_client","OAuth client credentials are malformed.")}if(bytes.toString("base64").replace(/=+$/,"")!==encoded.replace(/=+$/,""))reject("invalid_client","OAuth client credentials are malformed.");let decoded;try{decoded=new TextDecoder("utf-8",{fatal:!0}).decode(bytes)}catch{return reject("invalid_client","OAuth client credentials are not valid UTF-8.")}const separator=decoded.indexOf(":");if(separator<1)reject("invalid_client","OAuth client credentials are malformed.");const clientId=clientIdentifier(decodeCredential(decoded.slice(0,separator))),clientSecret=decodeCredential(decoded.slice(separator+1));if(!clientSecret||clientSecret.length>4096)reject("invalid_client","OAuth client secret is invalid.");return{clientId,clientSecret}}function clientCredentials(params,authorization){const bodyClientId=single(params,"client_id"),bodyClientSecret=single(params,"client_secret");if(authorization){if(bodyClientId!==void 0||bodyClientSecret!==void 0)reject("invalid_request","OAuth client authentication methods must not be combined.");return basicCredentials(authorization)}if(bodyClientSecret!==void 0)reject("invalid_request","OAuth client secrets must use Basic authentication.");return{clientId:clientIdentifier(bodyClientId??"")}}function tokenValue(params,name){const value=single(params,name,!0);if(value.length>4096||/\s/.test(value))reject("invalid_request",`OAuth token parameter is invalid: ${name}`);return value}function requestedScopes(params){const value=single(params,"scope");if(value===void 0)return;const scopes=value.split(" ");if(scopes.some((scope)=>!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))||new Set(scopes).size!==scopes.length)reject("invalid_request","OAuth scope must contain unique scope tokens.");return scopes}export function parseOAuthTokenRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth token request is too large.");const params=new URLSearchParams(body);for(const name of["grant_type","client_id","client_secret","code","redirect_uri","code_verifier","refresh_token","scope"])single(params,name);const credentials=clientCredentials(params,authorization),audiences=params.getAll("resource");if(audiences.length>10||new Set(audiences).size!==audiences.length||audiences.some((value)=>{try{const url=new URL(value);return!!(url.username||url.password||url.hash)}catch{return!0}}))reject("invalid_request","OAuth resource must contain unique absolute URIs without credentials or fragments.");if(audiences.length)credentials.audiences=audiences;const grantType=single(params,"grant_type",!0);if(grantType==="authorization_code"){const codeVerifier=single(params,"code_verifier",!0);if(!/^[A-Za-z0-9\-._~]{43,128}$/.test(codeVerifier))reject("invalid_request","OAuth PKCE verifier is invalid.");const redirectUri=single(params,"redirect_uri",!0);if(redirectUri.length>2048)reject("invalid_request","OAuth redirect URI is too large.");return{grantType,...credentials,code:tokenValue(params,"code"),redirectUri,codeVerifier}}if(grantType==="refresh_token"){const scopes=requestedScopes(params);return{grantType,...credentials,refreshToken:tokenValue(params,"refresh_token"),...scopes?{scopes}:{}}}if(grantType==="client_credentials"){if(!authorization||!credentials.clientSecret)reject("invalid_client","The client_credentials grant requires Basic client authentication.");const scopes=requestedScopes(params);return{grantType,...credentials,...scopes?{scopes}:{}}}return reject("unsupported_grant_type","OAuth grant type is not supported.")}export function parseOAuthRevocationRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth revocation request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth revocation token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}export function parseOAuthIntrospectionRequest(body,authorization){if(body.length>16384)reject("invalid_request","OAuth introspection request is too large.");const params=new URLSearchParams(body);for(const name of["client_id","client_secret","token","token_type_hint"])single(params,name);if(!authorization)reject("invalid_client","OAuth introspection requires Basic client authentication.");const credentials=clientCredentials(params,authorization),token=tokenValue(params,"token"),tokenTypeHint=single(params,"token_type_hint");if(tokenTypeHint!==void 0&&tokenTypeHint!=="access_token"&&tokenTypeHint!=="refresh_token")reject("unsupported_token_type","OAuth introspection token type is not supported.");return{...credentials,token,...tokenTypeHint?{tokenTypeHint}:{}}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/auth",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.193",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -53,23 +53,24 @@
|
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "bun build.ts",
|
|
55
55
|
"typecheck": "bun tsc --noEmit",
|
|
56
|
+
"pretypecheck:types": "bun run build",
|
|
56
57
|
"typecheck:types": "bun tsc --noEmit -p tsconfig.type-tests.json --pretty false",
|
|
57
58
|
"prepublishOnly": "bun run build"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
61
|
"@stacksjs/bun-router": "^0.1.24",
|
|
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.
|
|
62
|
+
"@stacksjs/cache": "0.75.193",
|
|
63
|
+
"@stacksjs/config": "0.75.193",
|
|
64
|
+
"@stacksjs/database": "0.75.193",
|
|
65
|
+
"@stacksjs/email": "0.75.193",
|
|
66
|
+
"@stacksjs/env": "0.75.193",
|
|
67
|
+
"@stacksjs/error-handling": "0.75.193",
|
|
68
|
+
"@stacksjs/logging": "0.75.193",
|
|
69
|
+
"@stacksjs/orm": "0.75.193",
|
|
70
|
+
"@stacksjs/path": "0.75.193",
|
|
71
|
+
"@stacksjs/router": "0.75.193",
|
|
72
|
+
"@stacksjs/security": "0.75.193",
|
|
73
|
+
"@stacksjs/storage": "0.75.193",
|
|
73
74
|
"@stacksjs/ts-auth": "^0.4.5",
|
|
74
75
|
"ts-qr-codes": "^0.1.8"
|
|
75
76
|
},
|