@stacksjs/auth 0.75.15 → 0.75.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/authentication.d.ts +1 -0
- package/dist/authentication.js +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-codes.d.ts +32 -0
- package/dist/oauth-authorization-codes.js +29 -0
- package/dist/oauth-authorization-requests.d.ts +9 -0
- package/dist/oauth-authorization-requests.js +37 -0
- package/dist/oauth-authorization.d.ts +55 -0
- package/dist/oauth-authorization.js +1 -0
- package/dist/oauth-client-registration.d.ts +78 -0
- package/dist/oauth-client-registration.js +78 -0
- package/dist/oauth-consent.d.ts +24 -0
- package/dist/oauth-consent.js +1 -0
- package/dist/oauth-delegated-access.d.ts +23 -0
- package/dist/oauth-delegated-access.js +1 -0
- package/dist/oauth-grants.d.ts +38 -0
- package/dist/oauth-grants.js +45 -0
- package/dist/oauth-pkce.d.ts +13 -0
- package/dist/oauth-pkce.js +1 -0
- package/dist/oauth-provider.d.ts +36 -0
- package/dist/oauth-provider.js +1 -0
- package/dist/oauth-revocation.d.ts +7 -0
- package/dist/oauth-revocation.js +13 -0
- package/dist/oauth-token-endpoint.d.ts +8 -0
- package/dist/oauth-token-endpoint.js +1 -0
- package/dist/oauth-token-exchange.d.ts +60 -0
- package/dist/oauth-token-exchange.js +65 -0
- package/dist/oauth-token-request.d.ts +36 -0
- package/dist/oauth-token-request.js +1 -0
- package/dist/oauth-token-response.d.ts +14 -0
- package/dist/oauth-token-response.js +1 -0
- package/dist/tokens.js +23 -9
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -28,6 +28,26 @@ import auth from '@stacksjs/auth'
|
|
|
28
28
|
|
|
29
29
|
Learn more in the docs.
|
|
30
30
|
|
|
31
|
+
## OAuth authorization provider
|
|
32
|
+
|
|
33
|
+
The authorization provider is opt-in through `config/auth.ts`. Enabling it
|
|
34
|
+
requires an explicit issuer, registered scopes and resources, and a reviewed
|
|
35
|
+
client policy. Provider routes remain unavailable until the complete consent
|
|
36
|
+
and token-exchange flow is registered.
|
|
37
|
+
|
|
38
|
+
The framework currently exports protocol primitives for incremental provider
|
|
39
|
+
integration:
|
|
40
|
+
|
|
41
|
+
- `resolveOAuthProviderConfig()` validates and snapshots provider policy.
|
|
42
|
+
- `validateOAuthClientRegistration()` validates client-controlled metadata.
|
|
43
|
+
- `validateOAuthAuthorizationRequest()` validates exact redirects, scopes,
|
|
44
|
+
resources, and S256 PKCE before login or consent.
|
|
45
|
+
- `issueAuthorizationCode()` and `withAuthorizationCode()` provide hash-only,
|
|
46
|
+
short-lived, atomic, single-use authorization codes.
|
|
47
|
+
|
|
48
|
+
These primitives do not turn personal access tokens into delegated OAuth
|
|
49
|
+
tokens, and they do not register public endpoints by themselves.
|
|
50
|
+
|
|
31
51
|
## 🧪 Testing
|
|
32
52
|
|
|
33
53
|
```bash
|
package/dist/authentication.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export declare class Auth {
|
|
|
20
20
|
static setUser(user: UserModel | undefined): void;
|
|
21
21
|
static createTokenForUser(user: UserModel, options?: TokenCreateOptions): Promise<NewAccessToken>;
|
|
22
22
|
static createToken(user: UserModel, name?: string, abilities?: string[]): Promise<AuthToken>;
|
|
23
|
+
static requestUserTokenWithClient(credentials: AuthCredentials, clientId: number, clientSecret: string): Promise<{ token: AuthToken } | null>;
|
|
23
24
|
static requestToken(credentials: AuthCredentials, clientId: number, clientSecret: string): Promise<{ token: AuthToken } | null>;
|
|
24
25
|
static validateToken(token: string): Promise<boolean>;
|
|
25
26
|
static getUserFromToken(token: string): Promise<UserModel | undefined>;
|
package/dist/authentication.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime}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{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";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")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";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).selectAll().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).selectAll().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).selectAll().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 user=await this.verifyCredentials(credentials);if(!user)return!1;const state=authStateOrNull();if(state)state.authUser=user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return null;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),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}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const user=await this.verifyCredentials(credentials);if(!user)return null;return withVerifiedPassword(user.id,user.password,()=>issue(user))}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??Math.max(1,Math.round((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 requestToken(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 validateToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().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;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();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)).selectAll().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(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))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).selectAll().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(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}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).selectAll();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",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),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}}}
|
|
1
|
+
import{config}from"@stacksjs/config";import{db,getDatabaseDialect,isInTransaction,parseSqlDateTime}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{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";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")}import{createToken as createRawToken,DEFAULT_TOKENABLE_TYPE,deleteExpiredTokens,getPasswordChangedAt,isIssuedBeforePasswordChange,parseScopes}from"./tokens";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).selectAll().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).selectAll().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).selectAll().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 user=await this.verifyCredentials(credentials);if(!user)return!1;const state=authStateOrNull();if(state)state.authUser=user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return null;const isRateLimited=await RateLimiter.isRateLimited(email),user=await User.where("email","=",email).first(),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}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const user=await this.verifyCredentials(credentials);if(!user)return null;return withVerifiedPassword(user.id,user.password,()=>issue(user))}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??Math.max(1,Math.round((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).selectAll().executeTakeFirst();if(!accessToken||accessToken.tokenable_type!==DEFAULT_TOKENABLE_TYPE)return!1;log.debug(`[auth] Token validated for token#${accessToken.id}`);if(accessToken.expires_at!=null&&(parseSqlDateTime(accessToken.expires_at)??new Date(0))<=new Date)return!1;if(accessToken.revoked)return!1;if(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))return!1;await db.updateTable("oauth_access_tokens").set({updated_at:formatDate(new Date)}).where("id","=",accessToken.id).execute();return!0}static async getUserFromToken(token){const hashedPlainToken=hashToken(token),accessToken=await db.primary.selectFrom("oauth_access_tokens").where("token","=",hashedPlainToken).selectAll().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;const idleMs=config.auth?.idleTimeout??0;if(idleMs>0){const lastSeen=parseSqlDateTime(accessToken.updated_at??accessToken.created_at);if(lastSeen&&Date.now()-lastSeen.getTime()>idleMs){await db.updateTable("oauth_access_tokens").set({revoked:!0}).where("id","=",accessToken.id).execute();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)).selectAll().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(isIssuedBeforePasswordChange(accessToken.created_at,await getPasswordChangedAt(accessToken.tokenable_id)))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).selectAll().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(){const result=await db.deleteFrom("oauth_access_tokens").where("revoked","=",!0).executeTakeFirst();return Number(result?.numDeletedRows)||0}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).selectAll();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",email=credentials[username];if(!email)return!1;const user=await User.where("email","=",email).first(),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
|
@@ -3,6 +3,20 @@ export * from './authentication';
|
|
|
3
3
|
export * from './authenticator';
|
|
4
4
|
export * from './client';
|
|
5
5
|
export * from './middleware';
|
|
6
|
+
export * from './oauth-authorization';
|
|
7
|
+
export * from './oauth-authorization-requests';
|
|
8
|
+
export * from './oauth-authorization-codes';
|
|
9
|
+
export * from './oauth-client-registration';
|
|
10
|
+
export * from './oauth-consent';
|
|
11
|
+
export * from './oauth-delegated-access';
|
|
12
|
+
export * from './oauth-grants';
|
|
13
|
+
export * from './oauth-provider';
|
|
14
|
+
export * from './oauth-pkce';
|
|
15
|
+
export * from './oauth-revocation';
|
|
16
|
+
export * from './oauth-token-exchange';
|
|
17
|
+
export * from './oauth-token-endpoint';
|
|
18
|
+
export * from './oauth-token-request';
|
|
19
|
+
export * from './oauth-token-response';
|
|
6
20
|
export * from './rate-limiter';
|
|
7
21
|
// WebAuthn/Passkey support (now using ts-auth - no external dependencies)
|
|
8
22
|
export * from './magic-link';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
1
|
+
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-codes";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-provider";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Issue a high-entropy code while storing only its SHA-256 hash. */
|
|
2
|
+
export declare function issueAuthorizationCode(input: IssueAuthorizationCodeInput): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Claim one code and complete credential issuance in the same transaction.
|
|
5
|
+
* A failed completion rolls the claim back so an infrastructure failure does
|
|
6
|
+
* not strand a valid grant. Concurrent successful commits remain single-use.
|
|
7
|
+
*/
|
|
8
|
+
export declare function withAuthorizationCode<T>(rawCode: string, expected: AuthorizationCodeRedemption, complete: (grant: AuthorizationCodeGrant) => Promise<T>): Promise<AuthorizationCodeResult<T>>;
|
|
9
|
+
export declare interface IssueAuthorizationCodeInput {
|
|
10
|
+
grantId: string
|
|
11
|
+
redirectUri: string
|
|
12
|
+
codeChallenge: string
|
|
13
|
+
lifetimeMs: number
|
|
14
|
+
}
|
|
15
|
+
export declare interface AuthorizationCodeRedemption {
|
|
16
|
+
clientId: number
|
|
17
|
+
redirectUri: string
|
|
18
|
+
codeVerifier: string
|
|
19
|
+
}
|
|
20
|
+
export declare interface AuthorizationCodeGrant {
|
|
21
|
+
grantId: string
|
|
22
|
+
clientId: number
|
|
23
|
+
subjectType: string
|
|
24
|
+
subjectId: number
|
|
25
|
+
redirectUri: string
|
|
26
|
+
scopes: string[]
|
|
27
|
+
resources: string[]
|
|
28
|
+
audiences: string[]
|
|
29
|
+
workspaceId: string | null
|
|
30
|
+
}
|
|
31
|
+
export type AuthorizationCodeResult<T> = | { ok: true, value: T }
|
|
32
|
+
| { ok: false, reason: 'invalid_grant' }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isFinite(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be positive.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
|
|
2
|
+
SELECT id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id
|
|
3
|
+
FROM oauth_grants
|
|
4
|
+
WHERE id = ${sql.param(1)} AND revoked_at IS NULL
|
|
5
|
+
LIMIT 1${grantLock}
|
|
6
|
+
`,[input.grantId]))[0];if(!grant)throw Error("OAuth authorization grant is not active.");const bound=sql.params(codeHash(plainTextCode),input.grantId,grant.client_id,grant.subject_type,grant.subject_id,input.redirectUri,grant.scopes,grant.resources,grant.audiences,grant.workspace_id??null,input.codeChallenge,"S256",sqlDateTime(new Date(createdAt.getTime()+input.lifetimeMs)),sqlDateTime(createdAt));await trx.unsafe(`
|
|
7
|
+
INSERT INTO oauth_auth_codes (
|
|
8
|
+
code_hash, grant_id, client_id, subject_type, subject_id, redirect_uri, scopes, resources,
|
|
9
|
+
audiences, workspace_id, code_challenge, code_challenge_method, expires_at, created_at
|
|
10
|
+
) VALUES (${bound.sql})
|
|
11
|
+
`,bound.values)});markContextWrote();return plainTextCode}export async function withAuthorizationCode(rawCode,expected,complete){if(!AUTHORIZATION_CODE_PATTERN.test(rawCode)||!validIdentifier(expected.clientId))return invalidGrant;const sql=sqlHelpers(getDatabaseDialect()),hash=codeHash(rawCode),lock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,row=(await trx.unsafe(`
|
|
12
|
+
SELECT c.code_hash, c.grant_id, c.client_id, c.subject_type, c.subject_id, c.redirect_uri,
|
|
13
|
+
c.scopes, c.resources, c.audiences, c.workspace_id, c.code_challenge,
|
|
14
|
+
c.code_challenge_method, c.expires_at, c.consumed_at,
|
|
15
|
+
g.client_id AS grant_client_id, g.subject_type AS grant_subject_type,
|
|
16
|
+
g.subject_id AS grant_subject_id, g.scopes AS grant_scopes,
|
|
17
|
+
g.resources AS grant_resources, g.audiences AS grant_audiences,
|
|
18
|
+
g.workspace_id AS grant_workspace_id
|
|
19
|
+
FROM oauth_auth_codes c
|
|
20
|
+
JOIN oauth_grants g ON g.id = c.grant_id AND g.revoked_at IS NULL
|
|
21
|
+
WHERE c.code_hash = ${sql.param(1)}
|
|
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(`
|
|
24
|
+
UPDATE oauth_auth_codes
|
|
25
|
+
SET consumed_at = ${sql.param(1)}
|
|
26
|
+
WHERE code_hash = ${sql.param(2)}
|
|
27
|
+
AND consumed_at IS NULL
|
|
28
|
+
AND expires_at > ${sql.param(3)}
|
|
29
|
+
`,[now,hash,now]);if(mutationCount(claimed)!==1)return invalidGrant;return{ok:!0,value:await complete({grantId:row.grant_id,clientId,subjectType:row.subject_type,subjectId,redirectUri:row.redirect_uri,scopes,resources,audiences,workspaceId:row.workspace_id})}});if(result.ok)markContextWrote();return result}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
|
|
2
|
+
/** Store one validated authorization request behind an opaque, hashed handle. */
|
|
3
|
+
export declare function createOAuthAuthorizationRequestSession(request: ValidatedOAuthAuthorizationRequest, browserSessionId: string, lifetimeMs: number): Promise<string>;
|
|
4
|
+
/** Reload a request only for its original browser and current client policy. */
|
|
5
|
+
export declare function loadOAuthAuthorizationRequestSession(requestId: string, browserSessionId: string): Promise<ValidatedOAuthAuthorizationRequest | null>;
|
|
6
|
+
/** Claim one browser-bound request and complete consent in the same transaction. */
|
|
7
|
+
export declare function withOAuthAuthorizationRequestSession<T>(requestId: string, browserSessionId: string, complete: (request: ValidatedOAuthAuthorizationRequest) => Promise<T>): Promise<OAuthAuthorizationRequestSessionResult<T>>;
|
|
8
|
+
export type OAuthAuthorizationRequestSessionResult<T> = | { ok: true, value: T }
|
|
9
|
+
| { ok: false, reason: 'invalid_request' }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function includesAll(allowed,requested){return requested.every((value)=>allowed.includes(value))}function inactive(value){return value===!1||value===0}function requestFromStoredRow(row,now){const clientId=Number(row.client_id);if(!Number.isSafeInteger(clientId)||clientId<=0||row.client_type!=="public"&&row.client_type!=="confidential"||!isValidOAuthRedirectUri(row.redirect_uri)||row.code_challenge_method!=="S256"||!isValidS256CodeChallenge(row.code_challenge)||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=now||row.consumed_at!=null||row.state!=null&&row.state.length>4096)return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes||!resources||!audiences||resources.length!==audiences.length)return null;return{responseType:"code",clientId:String(clientId),clientType:row.client_type,redirectUri:row.redirect_uri,scopes,resources,audiences,state:row.state,codeChallenge:row.code_challenge,codeChallengeMethod:"S256"}}function policyAllowsRequest(row,request){if(!row||String(row.id)!==request.clientId)return!1;const redirectUris=storedValues(row.redirect_uris),grantTypes=storedValues(row.grant_types),scopes=storedValues(row.allowed_scopes),resources=storedValues(row.allowed_resources),method=row.token_endpoint_auth_method;if(!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources)return!1;return row.client_type===request.clientType&&inactive(row.revoked)&&inactive(row.personal_access_client)&&inactive(row.password_client)&&(row.client_type==="public"&&method==="none"&&row.secret==null||row.client_type==="confidential"&&method==="client_secret_basic"&&Boolean(row.secret))&&redirectUris.includes(request.redirectUri)&&row.redirect===redirectUris[0]&&new Set(redirectUris).size===redirectUris.length&&new Set(grantTypes).size===grantTypes.length&&new Set(scopes).size===scopes.length&&new Set(resources).size===resources.length&&redirectUris.every(isValidOAuthRedirectUri)&&includesAll(scopes,request.scopes)&&includesAll(resources,request.resources)}export async function createOAuthAuthorizationRequestSession(request,browserSessionId,lifetimeMs){const clientId=Number(request.clientId);if(!Number.isSafeInteger(clientId)||clientId<=0)throw TypeError("OAuth authorization request client identifier is invalid.");if(!validBrowserSession(browserSessionId))throw TypeError("OAuth authorization request requires a valid browser session identifier.");if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth authorization request lifetime must be a positive safe integer.");if(request.state!=null&&request.state.length>4096)throw TypeError("OAuth authorization request state exceeds 4096 characters.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
2
|
+
INSERT INTO oauth_authorization_requests (
|
|
3
|
+
request_hash, browser_session_hash, client_id, client_type, redirect_uri,
|
|
4
|
+
scopes, resources, audiences, state, code_challenge, code_challenge_method,
|
|
5
|
+
expires_at, created_at
|
|
6
|
+
) VALUES (${values.sql})
|
|
7
|
+
`,values.values);markContextWrote();return id}export async function loadOAuthAuthorizationRequestSession(requestId,browserSessionId){if(!REQUEST_ID_PATTERN.test(requestId)||!validBrowserSession(browserSessionId))return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
|
|
8
|
+
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
|
|
9
|
+
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
10
|
+
FROM oauth_authorization_requests
|
|
11
|
+
WHERE request_hash = ${sql.param(1)}
|
|
12
|
+
AND browser_session_hash = ${sql.param(2)}
|
|
13
|
+
AND consumed_at IS NULL
|
|
14
|
+
AND expires_at > ${sql.param(3)}
|
|
15
|
+
LIMIT 1
|
|
16
|
+
`,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!REQUEST_ID_PATTERN.test(requestId)||!validBrowserSession(browserSessionId))return invalidRequest;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=new Date,nowSql=sqlDateTime(now),rows=await trx.unsafe(`
|
|
17
|
+
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
|
|
18
|
+
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
19
|
+
FROM oauth_authorization_requests
|
|
20
|
+
WHERE request_hash = ${sql.param(1)}
|
|
21
|
+
AND browser_session_hash = ${sql.param(2)}
|
|
22
|
+
LIMIT 1${requestLock}
|
|
23
|
+
`,[hash,sessionHash]),request=rows[0]?requestFromStoredRow(rows[0],now.getTime()):null;if(!request)return invalidRequest;const clients=await trx.unsafe(`
|
|
24
|
+
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
25
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
26
|
+
personal_access_client, password_client, revoked
|
|
27
|
+
FROM oauth_clients
|
|
28
|
+
WHERE id = ${sql.param(1)}
|
|
29
|
+
LIMIT 1${clientLock}
|
|
30
|
+
`,[Number(request.clientId)]);if(!policyAllowsRequest(clients[0],request))return invalidRequest;const claimed=await trx.unsafe(`
|
|
31
|
+
UPDATE oauth_authorization_requests
|
|
32
|
+
SET consumed_at = ${sql.param(1)}
|
|
33
|
+
WHERE request_hash = ${sql.param(2)}
|
|
34
|
+
AND browser_session_hash = ${sql.param(3)}
|
|
35
|
+
AND consumed_at IS NULL
|
|
36
|
+
AND expires_at > ${sql.param(4)}
|
|
37
|
+
`,[nowSql,hash,sessionHash,nowSql]);if(mutationCount(claimed)!==1)return invalidRequest;return{ok:!0,value:await complete(request)}});if(result.ok)markContextWrote();return result}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** Whether a registered callback is safe for exact redirect matching. */
|
|
3
|
+
export declare function isValidOAuthRedirectUri(value: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Validate the complete, browser-facing authorization request boundary.
|
|
6
|
+
*
|
|
7
|
+
* The caller loads the client from authoritative storage. This function then
|
|
8
|
+
* refuses to expose a redirect target until that client is active and the URI
|
|
9
|
+
* is an exact, secure registration match. Routes can therefore return local
|
|
10
|
+
* errors for forged client or redirect input without creating an open redirect.
|
|
11
|
+
*/
|
|
12
|
+
export declare function validateOAuthAuthorizationRequest(provider: ResolvedOAuthProviderConfig, client: OAuthAuthorizationClientRegistration, input: OAuthAuthorizationRequestInput): ValidatedOAuthAuthorizationRequest;
|
|
13
|
+
export declare interface OAuthAuthorizationClientRegistration {
|
|
14
|
+
id: string | number
|
|
15
|
+
type: 'confidential' | 'public'
|
|
16
|
+
revoked: boolean
|
|
17
|
+
redirectUris: readonly string[]
|
|
18
|
+
grantTypes: readonly string[]
|
|
19
|
+
scopes: readonly string[]
|
|
20
|
+
resources: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
export declare interface OAuthAuthorizationRequestInput {
|
|
23
|
+
responseType: string
|
|
24
|
+
clientId: string
|
|
25
|
+
redirectUri: string
|
|
26
|
+
scope?: string
|
|
27
|
+
resource?: string | readonly string[]
|
|
28
|
+
state?: string
|
|
29
|
+
codeChallenge?: string
|
|
30
|
+
codeChallengeMethod?: string
|
|
31
|
+
}
|
|
32
|
+
export declare interface ValidatedOAuthAuthorizationRequest {
|
|
33
|
+
responseType: 'code'
|
|
34
|
+
clientId: string
|
|
35
|
+
clientType: 'confidential' | 'public'
|
|
36
|
+
redirectUri: string
|
|
37
|
+
scopes: string[]
|
|
38
|
+
resources: string[]
|
|
39
|
+
audiences: string[]
|
|
40
|
+
state: string | null
|
|
41
|
+
codeChallenge: string
|
|
42
|
+
codeChallengeMethod: 'S256'
|
|
43
|
+
}
|
|
44
|
+
export type OAuthAuthorizationRequestErrorCode = | 'invalid_request'
|
|
45
|
+
| 'unauthorized_client'
|
|
46
|
+
| 'unsupported_response_type'
|
|
47
|
+
| 'invalid_scope'
|
|
48
|
+
| 'invalid_target';
|
|
49
|
+
/** A protocol failure plus the only callback target that is safe to use. */
|
|
50
|
+
export declare class OAuthAuthorizationRequestError extends Error {
|
|
51
|
+
public readonly code: OAuthAuthorizationRequestErrorCode;
|
|
52
|
+
public readonly redirectUri?: string | null;
|
|
53
|
+
public readonly state?: string | null;
|
|
54
|
+
constructor(code: OAuthAuthorizationRequestErrorCode, message: string, redirectUri?: string | null, state?: string | null);
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}export function isValidOAuthRedirectUri(value){if(value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}function requestedScopes(value,redirectUri,state){if(value==null||value==="")return[];const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!provider.scopes[scope]||!client.scopes.includes(scope))reject("invalid_scope",`OAuth scope is not registered for this client: ${scope}`,redirectUri,state);const audiences=requestedAudiences(input.resource,redirectUri,state),resources=[];for(const audience of audiences){const matchingResources=Object.entries(provider.resources).filter(([,resource])=>resource.audience===audience).map(([key])=>key);if(matchingResources.length!==1||!client.resources.includes(matchingResources[0]))reject("invalid_target",`OAuth resource is not registered for this client: ${audience}`,redirectUri,state);resources.push(matchingResources[0])}for(const scope of scopes){const allowedResources=provider.scopes[scope]?.resources;if(!allowedResources?.length)continue;if(!resources.length||resources.some((resource)=>!allowedResources.includes(resource)))reject("invalid_target",`OAuth scope is not valid for the requested resource: ${scope}`,redirectUri,state)}return{responseType:"code",clientId:input.clientId,clientType:client.type,redirectUri,scopes,resources,audiences,state,codeChallenge:input.codeChallenge,codeChallengeMethod:"S256"}}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { sqlHelpers } from '@stacksjs/database/runtime';
|
|
2
|
+
import type { OAuthAuthorizationClientRegistration } from './oauth-authorization';
|
|
3
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
4
|
+
/** Validate client-controlled metadata before any secret or database row exists. */
|
|
5
|
+
export declare function validateOAuthClientRegistration(provider: ResolvedOAuthProviderConfig, input: OAuthClientRegistrationInput): ValidatedOAuthClientRegistration;
|
|
6
|
+
/** Load only complete provider registration metadata from authoritative storage. */
|
|
7
|
+
export declare function loadOAuthAuthorizationClient(clientId: string): Promise<OAuthAuthorizationClientRegistration | null>;
|
|
8
|
+
/** List complete provider clients owned by one account without exposing secret hashes. */
|
|
9
|
+
export declare function listOAuthClients(ownerId: number): Promise<ManagedOAuthClient[]>;
|
|
10
|
+
/** Edit one active owner-managed client without changing its authentication class. */
|
|
11
|
+
export declare function updateOAuthClient(provider: ResolvedOAuthProviderConfig, ownerId: number, clientId: number, input: OAuthClientUpdateInput): Promise<ManagedOAuthClient | null>;
|
|
12
|
+
/** Authenticate one provider client and hold its policy stable through completion. */
|
|
13
|
+
export declare function withAuthenticatedOAuthTokenClient<T>(clientId: string, clientSecret: string | undefined, complete: (client: OAuthAuthorizationClientRegistration) => Promise<T>): Promise<T | null>;
|
|
14
|
+
/** Validate and persist one owner-managed provider client. */
|
|
15
|
+
export declare function registerOAuthClient(provider: ResolvedOAuthProviderConfig, ownerId: number, input: OAuthClientRegistrationInput): Promise<RegisteredOAuthClientResult>;
|
|
16
|
+
/** Rotate one active confidential client's secret and reveal the replacement once. */
|
|
17
|
+
export declare function rotateOAuthClientSecret(ownerId: number, clientId: number): Promise<string | null>;
|
|
18
|
+
/** Disable one owner-managed client and revoke every credential it issued. */
|
|
19
|
+
export declare function disableOAuthClient(ownerId: number, clientId: number): Promise<boolean>;
|
|
20
|
+
export declare interface OAuthClientRegistrationInput {
|
|
21
|
+
name: string
|
|
22
|
+
type: 'confidential' | 'public'
|
|
23
|
+
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod
|
|
24
|
+
redirectUris: readonly string[]
|
|
25
|
+
grantTypes: readonly string[]
|
|
26
|
+
scopes: readonly string[]
|
|
27
|
+
resources: readonly string[]
|
|
28
|
+
}
|
|
29
|
+
export declare interface OAuthClientUpdateInput {
|
|
30
|
+
name: string
|
|
31
|
+
redirectUris: readonly string[]
|
|
32
|
+
grantTypes: readonly string[]
|
|
33
|
+
scopes: readonly string[]
|
|
34
|
+
resources: readonly string[]
|
|
35
|
+
}
|
|
36
|
+
export declare interface ValidatedOAuthClientRegistration extends OAuthClientRegistrationInput {
|
|
37
|
+
name: string
|
|
38
|
+
redirectUris: string[]
|
|
39
|
+
grantTypes: string[]
|
|
40
|
+
scopes: string[]
|
|
41
|
+
resources: string[]
|
|
42
|
+
requiresSecret: boolean
|
|
43
|
+
}
|
|
44
|
+
export declare interface RegisteredOAuthClient {
|
|
45
|
+
id: number
|
|
46
|
+
ownerId: number
|
|
47
|
+
name: string
|
|
48
|
+
type: 'confidential' | 'public'
|
|
49
|
+
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod
|
|
50
|
+
redirectUris: string[]
|
|
51
|
+
grantTypes: string[]
|
|
52
|
+
scopes: string[]
|
|
53
|
+
resources: string[]
|
|
54
|
+
revoked: false
|
|
55
|
+
createdAt: Date
|
|
56
|
+
}
|
|
57
|
+
export declare interface RegisteredOAuthClientResult {
|
|
58
|
+
client: RegisteredOAuthClient
|
|
59
|
+
plainTextSecret?: string
|
|
60
|
+
}
|
|
61
|
+
export declare interface ManagedOAuthClient {
|
|
62
|
+
id: number
|
|
63
|
+
ownerId: number
|
|
64
|
+
name: string
|
|
65
|
+
type: 'confidential' | 'public'
|
|
66
|
+
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod
|
|
67
|
+
redirectUris: readonly string[]
|
|
68
|
+
grantTypes: readonly string[]
|
|
69
|
+
scopes: readonly string[]
|
|
70
|
+
resources: readonly string[]
|
|
71
|
+
revoked: boolean
|
|
72
|
+
createdAt: Date
|
|
73
|
+
}
|
|
74
|
+
export type OAuthTokenEndpointAuthMethod = 'client_secret_basic' | 'none';
|
|
75
|
+
declare type OAuthSqlHelpers = ReturnType<typeof sqlHelpers>;
|
|
76
|
+
export declare class OAuthClientRegistrationError extends Error {
|
|
77
|
+
constructor(message: string);
|
|
78
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const redirectUris=explicitUnique("redirect URI",input.redirectUris,{required:!0});if(redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri)))reject("OAuth client redirect URIs must be exact HTTPS URLs or exact loopback HTTP URLs, without credentials, fragments, or wildcards.");const grantTypes=explicitUnique("grant type",input.grantTypes,{required:!0});if(!grantTypes.includes("authorization_code"))reject("OAuth client grant types must include authorization_code.");for(const grantType of grantTypes)if(!provider.grantTypes.includes(grantType))reject(`OAuth client requested an unsupported grant type: ${grantType}`);const scopes=explicitUnique("scope",input.scopes,{required:!0});for(const scope of scopes)if(!provider.scopes[scope])reject(`OAuth client requested an unregistered scope: ${scope}`);const resources=explicitUnique("resource",input.resources);for(const resource of resources)if(!provider.resources[resource])reject(`OAuth client requested an unregistered resource: ${resource}`);for(const scope of scopes){const allowedResources=provider.scopes[scope]?.resources;if(allowedResources?.length&&!resources.some((resource)=>allowedResources.includes(resource)))reject(`OAuth client scope requires a registered resource: ${scope}`)}return{name,type:input.type,tokenEndpointAuthMethod:input.tokenEndpointAuthMethod,redirectUris,grantTypes,scopes,resources,requiresSecret:input.type==="confidential"}}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function inactive(value){return value===!1||value===0}function sameValues(left,right){return left.length===right.length&&left.every((value,index)=>value===right[index])}async function revokeOAuthClientAuthorizationState(trx,sql,clientId,now){const revoked=sql.isPostgres?!0:1;await trx.unsafe(`
|
|
2
|
+
UPDATE oauth_grants
|
|
3
|
+
SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
4
|
+
WHERE client_id = ${sql.param(3)} AND revoked_at IS NULL
|
|
5
|
+
`,[now,now,clientId]);await trx.unsafe(`
|
|
6
|
+
UPDATE oauth_auth_codes
|
|
7
|
+
SET consumed_at = ${sql.param(1)}
|
|
8
|
+
WHERE client_id = ${sql.param(2)} AND consumed_at IS NULL
|
|
9
|
+
`,[now,clientId]);await trx.unsafe(`
|
|
10
|
+
UPDATE oauth_authorization_requests
|
|
11
|
+
SET consumed_at = ${sql.param(1)}
|
|
12
|
+
WHERE client_id = ${sql.param(2)} AND consumed_at IS NULL
|
|
13
|
+
`,[now,clientId]);await trx.unsafe(`
|
|
14
|
+
UPDATE oauth_refresh_tokens
|
|
15
|
+
SET revoked = ${sql.param(1)}
|
|
16
|
+
WHERE access_token_id IN (
|
|
17
|
+
SELECT id FROM oauth_access_tokens WHERE oauth_client_id = ${sql.param(2)}
|
|
18
|
+
)
|
|
19
|
+
`,[revoked,clientId]);await trx.unsafe(`
|
|
20
|
+
UPDATE oauth_access_tokens
|
|
21
|
+
SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
22
|
+
WHERE oauth_client_id = ${sql.param(3)}
|
|
23
|
+
`,[revoked,now,clientId])}function storedFlag(value){if(value===!1||value===0)return!1;if(value===!0||value===1)return!0;return null}function authorizationClientFromStored(row,id){const redirectUris=storedValues(row?.redirect_uris??null),grantTypes=storedValues(row?.grant_types??null),scopes=storedValues(row?.allowed_scopes??null),resources=storedValues(row?.allowed_resources??null),revoked=row?storedFlag(row.revoked):null,type=row?.client_type,method=row?.token_endpoint_auth_method;if(!row||String(row.id)!==String(id)||type!=="public"&&type!=="confidential"||method!=="none"&&method!=="client_secret_basic"||(type==="public"?method!=="none"||row.secret!=null:method!=="client_secret_basic"||!row.secret)||!redirectUris?.length||!grantTypes?.includes("authorization_code")||!scopes?.length||!resources||new Set(redirectUris).size!==redirectUris.length||new Set(grantTypes).size!==grantTypes.length||new Set(scopes).size!==scopes.length||new Set(resources).size!==resources.length||redirectUris.some((uri)=>!isValidOAuthRedirectUri(uri))||row.redirect!==redirectUris[0]||!inactive(row.personal_access_client)||!inactive(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}async function storedAuthorizationClient(clientId){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
|
|
24
|
+
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
25
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
26
|
+
personal_access_client, password_client, revoked
|
|
27
|
+
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
|
|
28
|
+
`,[id]))[0],client=authorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
29
|
+
SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
30
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
31
|
+
password_client, revoked, created_at
|
|
32
|
+
FROM oauth_clients
|
|
33
|
+
WHERE user_id = ${sql.param(1)}
|
|
34
|
+
ORDER BY created_at DESC, id DESC
|
|
35
|
+
`,[ownerId])).flatMap((row)=>{const id=Number(row.id),storedOwnerId=Number(row.user_id),client=authorizationClientFromStored(row,id),createdAt=parseSqlDateTime(row.created_at);if(!client||!Number.isSafeInteger(storedOwnerId)||storedOwnerId!==ownerId||!createdAt)return[];return[{id,ownerId,name:row.name,type:client.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:client.revoked,createdAt}]})}export async function updateOAuthClient(provider,ownerId,clientId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,row=(await trx.unsafe(`
|
|
36
|
+
SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
37
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
38
|
+
password_client, revoked, created_at
|
|
39
|
+
FROM oauth_clients
|
|
40
|
+
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
41
|
+
LIMIT 1${lock}
|
|
42
|
+
`,[clientId,ownerId]))[0],current=authorizationClientFromStored(row,clientId),createdAt=row?parseSqlDateTime(row.created_at):null;if(!row||!current||current.revoked||!createdAt)return null;const validated=validateOAuthClientRegistration(provider,{...input,type:current.type,tokenEndpointAuthMethod:row.token_endpoint_auth_method}),policyChanged=!sameValues(current.redirectUris,validated.redirectUris)||!sameValues(current.grantTypes,validated.grantTypes)||!sameValues(current.scopes,validated.scopes)||!sameValues(current.resources,validated.resources),changed=policyChanged||row.name!==validated.name,client={id:clientId,ownerId,name:validated.name,type:current.type,tokenEndpointAuthMethod:validated.tokenEndpointAuthMethod,redirectUris:validated.redirectUris,grantTypes:validated.grantTypes,scopes:validated.scopes,resources:validated.resources,revoked:!1,createdAt};if(!changed)return{changed:!1,client};const active=sql.isPostgres?!1:0,now=sqlDateTime(new Date),updated=await trx.unsafe(`
|
|
43
|
+
UPDATE oauth_clients
|
|
44
|
+
SET name = ${sql.param(1)}, redirect = ${sql.param(2)}, redirect_uris = ${sql.param(3)},
|
|
45
|
+
grant_types = ${sql.param(4)}, allowed_scopes = ${sql.param(5)}, allowed_resources = ${sql.param(6)},
|
|
46
|
+
updated_at = ${sql.param(7)}
|
|
47
|
+
WHERE id = ${sql.param(8)} AND user_id = ${sql.param(9)} AND revoked = ${sql.param(10)}
|
|
48
|
+
`,[validated.name,validated.redirectUris[0],JSON.stringify(validated.redirectUris),JSON.stringify(validated.grantTypes),JSON.stringify(validated.scopes),JSON.stringify(validated.resources),now,clientId,ownerId,active]);if(mutationCount(updated)!==1)return null;if(policyChanged)await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return{changed:!0,client}});if(!result)return null;if(result.changed)markContextWrote();return result.client}export async function withAuthenticatedOAuthTokenClient(clientId,clientSecret,complete){const id=Number(clientId);if(!Number.isSafeInteger(id)||id<=0||clientSecret!=null&&clientSecret.length>4096)return null;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";return db.transaction(async(rawTrx)=>{const row=(await rawTrx.unsafe(`
|
|
49
|
+
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
50
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
51
|
+
personal_access_client, password_client, revoked
|
|
52
|
+
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1${lock}
|
|
53
|
+
`,[id]))[0],client=authorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0],client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
|
|
54
|
+
INSERT INTO oauth_clients (
|
|
55
|
+
user_id, name, secret, provider, redirect, client_type, redirect_uris, grant_types,
|
|
56
|
+
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
57
|
+
password_client, revoked, created_at
|
|
58
|
+
) VALUES (${values.sql})${returning}
|
|
59
|
+
`,values.values),idRows=sql.isMysql?await trx.unsafe("SELECT LAST_INSERT_ID() AS id"):inserted,id=Number(idRows[0]?.id);if(!Number.isSafeInteger(id)||id<=0)throw Error("Failed to resolve the registered OAuth client identifier.");const row=(await trx.unsafe(`SELECT * FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1`,[id]))[0];if(!row||String(row.user_id)!==String(ownerId)||row.name!==client.name||row.secret!==storedSecret||row.redirect!==client.redirectUris[0]||row.client_type!==client.type||JSON.stringify(storedValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedValues(row.allowed_resources))!==JSON.stringify(client.resources)||!inactive(row.personal_access_client)||!inactive(row.password_client)||!inactive(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
|
|
60
|
+
SELECT client_type, token_endpoint_auth_method, revoked
|
|
61
|
+
FROM oauth_clients
|
|
62
|
+
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
63
|
+
LIMIT 1${lock}
|
|
64
|
+
`,[clientId,ownerId]))[0];if(!client||client.client_type!=="confidential"||client.token_endpoint_auth_method!=="client_secret_basic"||!inactive(client.revoked))return!1;const active=sql.isPostgres?!1:0,changed=await trx.unsafe(`
|
|
65
|
+
UPDATE oauth_clients
|
|
66
|
+
SET secret = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
67
|
+
WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)}
|
|
68
|
+
AND client_type = ${sql.param(5)} AND token_endpoint_auth_method = ${sql.param(6)}
|
|
69
|
+
AND revoked = ${sql.param(7)}
|
|
70
|
+
`,[storedSecret,sqlDateTime(new Date),clientId,ownerId,"confidential","client_secret_basic",active]);return mutationCount(changed)===1}))return null;markContextWrote();return plainTextSecret}export async function disableOAuthClient(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disabled=await db.transaction(async(rawTrx)=>{const trx=rawTrx,rows=await trx.unsafe(`
|
|
71
|
+
SELECT id, revoked FROM oauth_clients
|
|
72
|
+
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
73
|
+
LIMIT 1${lock}
|
|
74
|
+
`,[clientId,ownerId]);if(!rows[0]||!inactive(rows[0].revoked))return!1;const now=sqlDateTime(new Date),revoked=sql.isPostgres?!0:1,active=sql.isPostgres?!1:0,changed=await trx.unsafe(`
|
|
75
|
+
UPDATE oauth_clients
|
|
76
|
+
SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
77
|
+
WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)} AND revoked = ${sql.param(5)}
|
|
78
|
+
`,[revoked,now,clientId,ownerId,active]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0});if(disabled)markContextWrote();return disabled}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { OAuthAuthorizationRequestSessionResult } from './oauth-authorization-requests';
|
|
2
|
+
/** Atomically turn one authenticated browser approval into a grant and code. */
|
|
3
|
+
export declare function approveOAuthAuthorizationRequestSession(input: ApproveOAuthAuthorizationRequestSessionInput): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationConsentResult>>;
|
|
4
|
+
/** Consume one browser-bound denial without creating a grant or authorization code. */
|
|
5
|
+
export declare function denyOAuthAuthorizationRequestSession(requestId: string, browserSessionId: string): Promise<OAuthAuthorizationRequestSessionResult<OAuthAuthorizationDenialResult>>;
|
|
6
|
+
export declare interface ApproveOAuthAuthorizationRequestSessionInput {
|
|
7
|
+
requestId: string
|
|
8
|
+
browserSessionId: string
|
|
9
|
+
subjectType: string
|
|
10
|
+
subjectId: number
|
|
11
|
+
workspaceId?: string | null
|
|
12
|
+
authorizationCodeLifetimeMs: number
|
|
13
|
+
}
|
|
14
|
+
export declare interface OAuthAuthorizationConsentResult {
|
|
15
|
+
code: string
|
|
16
|
+
grantId: string
|
|
17
|
+
redirectUri: string
|
|
18
|
+
state: string | null
|
|
19
|
+
}
|
|
20
|
+
export declare interface OAuthAuthorizationDenialResult {
|
|
21
|
+
error: 'access_denied'
|
|
22
|
+
redirectUri: string
|
|
23
|
+
state: string | null
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{issueAuthorizationCode}from"./oauth-authorization-codes";import{withOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{createOAuthGrant}from"./oauth-grants";export async function approveOAuthAuthorizationRequestSession(input){if(!Number.isSafeInteger(input.authorizationCodeLifetimeMs)||input.authorizationCodeLifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer.");return withOAuthAuthorizationRequestSession(input.requestId,input.browserSessionId,async(request)=>{const grant=await createOAuthGrant({clientId:Number(request.clientId),subjectType:input.subjectType,subjectId:input.subjectId,scopes:request.scopes,resources:request.resources,audiences:request.audiences,workspaceId:input.workspaceId});return{code:await issueAuthorizationCode({grantId:grant.id,redirectUri:request.redirectUri,codeChallenge:request.codeChallenge,lifetimeMs:input.authorizationCodeLifetimeMs}),grantId:grant.id,redirectUri:request.redirectUri,state:request.state}})}export async function denyOAuthAuthorizationRequestSession(requestId,browserSessionId){return withOAuthAuthorizationRequestSession(requestId,browserSessionId,async(request)=>({error:"access_denied",redirectUri:request.redirectUri,state:request.state}))}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AccessToken } from '@stacksjs/types';
|
|
2
|
+
/** Resolve and authorize a delegated bearer without promoting it to a user session. */
|
|
3
|
+
export declare function authorizeOAuthDelegatedToken(bearer: string, requirement: OAuthDelegatedAccessRequirement): Promise<OAuthDelegatedAccessResult>;
|
|
4
|
+
/** Format an RFC 6750 challenge for a rejected delegated bearer. */
|
|
5
|
+
export declare function oauthBearerAuthorizationErrorResponse(failure: OAuthDelegatedAccessFailure): Response;
|
|
6
|
+
export declare interface OAuthDelegatedSubject {
|
|
7
|
+
type: string
|
|
8
|
+
id: number
|
|
9
|
+
clientId: number
|
|
10
|
+
grantId: string
|
|
11
|
+
workspaceId: string | null
|
|
12
|
+
}
|
|
13
|
+
export declare interface OAuthDelegatedAccessRequirement {
|
|
14
|
+
scopes: readonly string[]
|
|
15
|
+
resource: string
|
|
16
|
+
audience: string
|
|
17
|
+
workspaceId: string | null
|
|
18
|
+
isSubjectEligible: (subject: OAuthDelegatedSubject) => boolean | Promise<boolean>
|
|
19
|
+
}
|
|
20
|
+
export type OAuthDelegatedAccessResult = { ok: true, token: AccessToken }
|
|
21
|
+
| { ok: false, reason: 'invalid_token' }
|
|
22
|
+
| { ok: false, reason: 'insufficient_scope', requiredScopes: string[] }
|
|
23
|
+
declare type OAuthDelegatedAccessFailure = Exclude<OAuthDelegatedAccessResult, { ok: true }>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{findToken}from"./tokens";const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function validateRequirement(requirement){if(!requirement.scopes.length||requirement.scopes.some((scope)=>!POLICY_TOKEN.test(scope))||new Set(requirement.scopes).size!==requirement.scopes.length)throw TypeError("OAuth delegated access requires unique non-empty scope tokens.");if(!POLICY_TOKEN.test(requirement.resource))throw TypeError("OAuth delegated access resource is invalid.");try{const audience=new URL(requirement.audience);if(audience.username||audience.password||audience.hash)throw TypeError("invalid audience")}catch{throw TypeError("OAuth delegated access audience must be an absolute URI without credentials or a fragment.")}if(requirement.workspaceId!=null&&(!requirement.workspaceId||requirement.workspaceId.length>255))throw TypeError("OAuth delegated access workspace identifier must be 1 to 255 characters.")}export async function authorizeOAuthDelegatedToken(bearer,requirement){validateRequirement(requirement);const token=await findToken(bearer),subjectType=token?.subjectType,subjectId=token?.subjectId,grantId=token?.grantId;if(!token||!subjectType||!Number.isSafeInteger(subjectId)||!grantId||!token.resources?.includes(requirement.resource)||!token.audiences?.includes(requirement.audience)||(token.workspaceId??null)!==requirement.workspaceId)return{ok:!1,reason:"invalid_token"};if(!await requirement.isSubjectEligible({type:subjectType,id:subjectId,clientId:token.clientId,grantId,workspaceId:token.workspaceId??null}))return{ok:!1,reason:"invalid_token"};if(!requirement.scopes.every((scope)=>token.scopes.includes(scope)))return{ok:!1,reason:"insufficient_scope",requiredScopes:[...requirement.scopes]};return{ok:!0,token}}export function oauthBearerAuthorizationErrorResponse(failure){const insufficient=failure.reason==="insufficient_scope",scope=insufficient?`, scope="${failure.requiredScopes.join(" ")}"`:"";return new Response(JSON.stringify({error:failure.reason}),{status:insufficient?403:401,headers:{"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8","WWW-Authenticate":`Bearer error="${failure.reason}"${scope}`}})}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Persist the exact consent a user approved. Grants are immutable until revoked. */
|
|
2
|
+
export declare function createOAuthGrant(input: CreateOAuthGrantInput): Promise<OAuthGrant>;
|
|
3
|
+
/** Revoke one consent grant. Replays are idempotent and report no new change. */
|
|
4
|
+
export declare function revokeOAuthGrant(id: string): Promise<boolean>;
|
|
5
|
+
/** List the active third-party applications connected to one OAuth subject. */
|
|
6
|
+
export declare function listOAuthConnections(subjectType: string, subjectId: number): Promise<OAuthConnectedApplication[]>;
|
|
7
|
+
/** Disconnect one application without allowing another subject to revoke it. */
|
|
8
|
+
export declare function disconnectOAuthGrant(subjectType: string, subjectId: number, grantId: string): Promise<boolean>;
|
|
9
|
+
export declare interface CreateOAuthGrantInput {
|
|
10
|
+
clientId: number
|
|
11
|
+
subjectType: string
|
|
12
|
+
subjectId: number
|
|
13
|
+
scopes: readonly string[]
|
|
14
|
+
resources: readonly string[]
|
|
15
|
+
audiences: readonly string[]
|
|
16
|
+
workspaceId?: string | null
|
|
17
|
+
}
|
|
18
|
+
export declare interface OAuthGrant {
|
|
19
|
+
id: string
|
|
20
|
+
clientId: number
|
|
21
|
+
subjectType: string
|
|
22
|
+
subjectId: number
|
|
23
|
+
scopes: string[]
|
|
24
|
+
resources: string[]
|
|
25
|
+
audiences: string[]
|
|
26
|
+
workspaceId: string | null
|
|
27
|
+
createdAt: Date
|
|
28
|
+
}
|
|
29
|
+
export declare interface OAuthConnectedApplication {
|
|
30
|
+
grantId: string
|
|
31
|
+
clientId: number
|
|
32
|
+
clientName: string
|
|
33
|
+
scopes: string[]
|
|
34
|
+
resources: string[]
|
|
35
|
+
audiences: string[]
|
|
36
|
+
workspaceId: string | null
|
|
37
|
+
createdAt: Date
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function explicitValues(name,values){if(values.some((value)=>!value)||new Set(values).size!==values.length)throw TypeError(`OAuth grant ${name} must contain unique non-empty values.`);return[...values]}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string"&&item.length>0)&&new Set(parsed).size===parsed.length?parsed:null}catch{return null}}export async function createOAuthGrant(input){if(!validIdentifier(input.clientId)||!validIdentifier(input.subjectId))throw TypeError("OAuth grant client and subject identifiers must be positive safe integers.");if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(input.subjectType))throw TypeError("OAuth grant subject type is invalid.");if(input.workspaceId!=null&&(!input.workspaceId||input.workspaceId.length>255))throw TypeError("OAuth grant workspace identifier must be 1 to 255 characters.");const scopes=explicitValues("scopes",input.scopes),resources=explicitValues("resources",input.resources),audiences=explicitValues("audiences",input.audiences),id=randomBytes(16).toString("hex"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect()),bound=sql.params(id,input.clientId,input.subjectType,input.subjectId,JSON.stringify(scopes),JSON.stringify(resources),JSON.stringify(audiences),input.workspaceId??null,sqlDateTime(createdAt));await db.transaction(async(rawTrx)=>{const trx=rawTrx,clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
|
|
2
|
+
SELECT id FROM oauth_clients
|
|
3
|
+
WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
|
|
4
|
+
LIMIT 1${clientLock}
|
|
5
|
+
`,[input.clientId])).length!==1)throw Error("OAuth client is not active.");await trx.unsafe(`
|
|
6
|
+
INSERT INTO oauth_grants (
|
|
7
|
+
id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id, created_at
|
|
8
|
+
) VALUES (${bound.sql})
|
|
9
|
+
`,bound.values)});markContextWrote();return{id,clientId:input.clientId,subjectType:input.subjectType,subjectId:input.subjectId,scopes,resources,audiences,workspaceId:input.workspaceId??null,createdAt}}export async function revokeOAuthGrant(id){if(!/^[a-f0-9]{32}$/.test(id))return!1;const sql=sqlHelpers(getDatabaseDialect()),now=sqlDateTime(new Date),result=await db.unsafe(`
|
|
10
|
+
UPDATE oauth_grants
|
|
11
|
+
SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
12
|
+
WHERE id = ${sql.param(3)} AND revoked_at IS NULL
|
|
13
|
+
`,[now,now,id]).execute(),revoked=mutationCount(result)===1;if(revoked)markContextWrote();return revoked}export async function listOAuthConnections(subjectType,subjectId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId))return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
14
|
+
SELECT g.id AS grant_id, g.client_id, c.name AS client_name, g.scopes,
|
|
15
|
+
g.resources, g.audiences, g.workspace_id, g.created_at
|
|
16
|
+
FROM oauth_grants g
|
|
17
|
+
JOIN oauth_clients c ON c.id = g.client_id AND c.revoked = ${sql.boolFalse}
|
|
18
|
+
WHERE g.subject_type = ${sql.param(1)} AND g.subject_id = ${sql.param(2)}
|
|
19
|
+
AND g.revoked_at IS NULL
|
|
20
|
+
ORDER BY g.created_at DESC, g.id DESC
|
|
21
|
+
`,[subjectType,subjectId])).flatMap((row)=>{const clientId=Number(row.client_id),scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences),createdAt=parseSqlDateTime(row.created_at);if(!/^[a-f0-9]{32}$/.test(row.grant_id)||!validIdentifier(clientId)||typeof row.client_name!=="string"||!row.client_name||!scopes||!resources||!audiences||!createdAt)return[];return[{grantId:row.grant_id,clientId,clientName:row.client_name,scopes,resources,audiences,workspaceId:row.workspace_id,createdAt}]})}export async function disconnectOAuthGrant(subjectType,subjectId,grantId){if(!/^[A-Za-z0-9_.:-]{1,255}$/.test(subjectType)||!validIdentifier(subjectId)||!/^[a-f0-9]{32}$/.test(grantId))return!1;const sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE",disconnected=await db.transaction(async(rawTrx)=>{const trx=rawTrx;if((await trx.unsafe(`
|
|
22
|
+
SELECT id FROM oauth_grants
|
|
23
|
+
WHERE id = ${sql.param(1)} AND subject_type = ${sql.param(2)}
|
|
24
|
+
AND subject_id = ${sql.param(3)} AND revoked_at IS NULL
|
|
25
|
+
LIMIT 1${lock}
|
|
26
|
+
`,[grantId,subjectType,subjectId])).length!==1)return!1;const now=sqlDateTime(new Date),changed=await trx.unsafe(`
|
|
27
|
+
UPDATE oauth_grants
|
|
28
|
+
SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
29
|
+
WHERE id = ${sql.param(3)} AND subject_type = ${sql.param(4)}
|
|
30
|
+
AND subject_id = ${sql.param(5)} AND revoked_at IS NULL
|
|
31
|
+
`,[now,now,grantId,subjectType,subjectId]);if(mutationCount(changed)!==1)return!1;await trx.unsafe(`
|
|
32
|
+
UPDATE oauth_auth_codes
|
|
33
|
+
SET consumed_at = ${sql.param(1)}
|
|
34
|
+
WHERE grant_id = ${sql.param(2)} AND consumed_at IS NULL
|
|
35
|
+
`,[now,grantId]);await trx.unsafe(`
|
|
36
|
+
UPDATE oauth_refresh_tokens
|
|
37
|
+
SET revoked = ${sql.boolTrue}
|
|
38
|
+
WHERE access_token_id IN (
|
|
39
|
+
SELECT id FROM oauth_access_tokens WHERE oauth_grant_id = ${sql.param(1)}
|
|
40
|
+
)
|
|
41
|
+
`,[grantId]);await trx.unsafe(`
|
|
42
|
+
UPDATE oauth_access_tokens
|
|
43
|
+
SET revoked = ${sql.boolTrue}, updated_at = ${sql.param(1)}
|
|
44
|
+
WHERE oauth_grant_id = ${sql.param(2)}
|
|
45
|
+
`,[now,grantId]);return!0});if(disconnected)markContextWrote();return disconnected}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Whether a value is an RFC 7636 code verifier. */
|
|
2
|
+
export declare function isValidPkceVerifier(value: unknown): value is string;
|
|
3
|
+
/** Whether a value is a 256-bit SHA-256 digest encoded as base64url. */
|
|
4
|
+
export declare function isValidS256CodeChallenge(value: unknown): value is string;
|
|
5
|
+
/** Generate a 256-bit, 43-character RFC 7636 verifier. */
|
|
6
|
+
export declare function generatePkceVerifier(): string;
|
|
7
|
+
/** Derive the required S256 challenge from a validated verifier. */
|
|
8
|
+
export declare function createS256CodeChallenge(verifier: string): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Verify an S256 challenge without supporting the insecure plain method.
|
|
11
|
+
* Malformed protocol input returns false instead of reaching the hash compare.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verifyS256CodeChallenge(verifier: string, expectedChallenge: string): Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash,randomBytes,timingSafeEqual}from"node:crypto";const VERIFIER_PATTERN=/^[A-Za-z0-9\-._~]{43,128}$/,S256_CHALLENGE_PATTERN=/^[A-Za-z0-9_-]{43}$/;export function isValidPkceVerifier(value){return typeof value==="string"&&VERIFIER_PATTERN.test(value)}export function isValidS256CodeChallenge(value){return typeof value==="string"&&S256_CHALLENGE_PATTERN.test(value)}export function generatePkceVerifier(){return randomBytes(32).toString("base64url")}export async function createS256CodeChallenge(verifier){if(!isValidPkceVerifier(verifier))throw TypeError("PKCE verifier must be 43 to 128 RFC 7636 unreserved characters.");return createHash("sha256").update(verifier,"ascii").digest("base64url")}export async function verifyS256CodeChallenge(verifier,expectedChallenge){if(!isValidPkceVerifier(verifier)||!isValidS256CodeChallenge(expectedChallenge))return!1;const actual=await createS256CodeChallenge(verifier);return timingSafeEqual(Buffer.from(actual,"ascii"),Buffer.from(expectedChallenge,"ascii"))}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { OAuthProviderConfig, OAuthProviderResourceConfig, OAuthProviderScopeConfig } from '@stacksjs/types';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve the disabled-by-default OAuth authorization-server contract.
|
|
4
|
+
*
|
|
5
|
+
* The initial provider profile is intentionally fixed to authorization code
|
|
6
|
+
* with S256 PKCE and rotating refresh tokens. Implicit and password grants are
|
|
7
|
+
* not configurable options, so enabling the provider cannot advertise them by
|
|
8
|
+
* accident. Routes consume this resolver before registration and therefore do
|
|
9
|
+
* not exist while the provider is absent or disabled.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveOAuthProviderConfig(options?: OAuthProviderConfig): ResolvedOAuthProviderConfig | null;
|
|
12
|
+
export declare interface ResolvedOAuthProviderConfig {
|
|
13
|
+
enabled: true
|
|
14
|
+
issuer: string
|
|
15
|
+
endpoints: {
|
|
16
|
+
authorization: string
|
|
17
|
+
token: string
|
|
18
|
+
revocation: string
|
|
19
|
+
introspection: string
|
|
20
|
+
}
|
|
21
|
+
responseTypes: readonly ['code']
|
|
22
|
+
grantTypes: readonly ['authorization_code', 'refresh_token']
|
|
23
|
+
codeChallengeMethods: readonly ['S256']
|
|
24
|
+
clientTypes: readonly ('confidential' | 'public')[]
|
|
25
|
+
scopes: Record<string, OAuthProviderScopeConfig>
|
|
26
|
+
resources: Record<string, OAuthProviderResourceConfig>
|
|
27
|
+
lifetimes: {
|
|
28
|
+
authorizationCode: number
|
|
29
|
+
accessToken: number
|
|
30
|
+
refreshToken: number
|
|
31
|
+
}
|
|
32
|
+
consent: {
|
|
33
|
+
rememberFor: number
|
|
34
|
+
view: string
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const DEFAULT_LIFETIMES={authorizationCode:600000,accessToken:3600000,refreshToken:2592000000},DEFAULT_ENDPOINTS={authorization:"oauth/authorize",token:"oauth/token",revocation:"oauth/revoke",introspection:"oauth/introspect"};function canonicalIssuer(value){if(!value)throw Error("OAuth provider is enabled but `auth.oauthProvider.issuer` is not configured.");let issuer;try{issuer=new URL(value)}catch{throw Error("`auth.oauthProvider.issuer` must be an absolute URL.")}if(issuer.username||issuer.password)throw Error("`auth.oauthProvider.issuer` must not contain credentials.");if(issuer.search||issuer.hash)throw Error("`auth.oauthProvider.issuer` must not contain a query or fragment.");const loopback=issuer.hostname==="localhost"||issuer.hostname==="127.0.0.1"||issuer.hostname==="[::1]";if(issuer.protocol!=="https:"&&!(issuer.protocol==="http:"&&loopback))throw Error("`auth.oauthProvider.issuer` must use HTTPS outside loopback development.");issuer.pathname=issuer.pathname.replace(/\/+$/,"")||"/";return issuer}function endpoint(issuer,configured,fallback){const resolved=new URL(configured??fallback,`${issuer.toString().replace(/\/+$/,"")}/`);if(resolved.origin!==issuer.origin)throw Error("OAuth provider endpoints must share the configured issuer origin.");if(resolved.username||resolved.password||resolved.search||resolved.hash)throw Error("OAuth provider endpoints must not contain credentials, a query, or a fragment.");return resolved.toString().replace(/\/$/,"")}function positiveLifetime(name,value){if(!Number.isFinite(value)||value<=0)throw Error(`auth.oauthProvider.lifetimes.${name} must be a positive number of milliseconds.`);return value}const POLICY_TOKEN=/^[\x21\x23-\x5B\x5D-\x7E]+$/;function resolvedResources(configured){const resources={},audiences=new Set;for(const[key,resource]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider resource key is not a valid policy token: ${key}`);let audience;try{audience=new URL(resource.audience)}catch{throw Error(`auth.oauthProvider resource audience must be an absolute URI: ${key}`)}if(audience.username||audience.password||audience.hash)throw Error(`auth.oauthProvider resource audience must not contain credentials or a fragment: ${key}`);if(audiences.has(resource.audience))throw Error(`auth.oauthProvider resources must each have a unique audience: ${resource.audience}`);audiences.add(resource.audience);resources[key]={audience:resource.audience,...resource.description===void 0?{}:{description:resource.description}}}return resources}function resolvedScopes(configured,resources){const scopes={};for(const[key,scope]of Object.entries(configured??{})){if(!POLICY_TOKEN.test(key))throw Error(`auth.oauthProvider scope is not a valid OAuth scope token: ${key}`);if(!scope.description?.trim())throw Error(`auth.oauthProvider scope description must not be empty: ${key}`);const scopeResources=[...scope.resources??[]];if(new Set(scopeResources).size!==scopeResources.length)throw Error(`auth.oauthProvider scope contains a duplicate resource: ${key}`);for(const resource of scopeResources)if(!resources[resource])throw Error(`auth.oauthProvider scope references an unknown resource: ${key} -> ${resource}`);scopes[key]={description:scope.description,...scope.resources===void 0?{}:{resources:scopeResources}}}return scopes}function resolvedClientTypes(configured){const clientTypes=[...configured??["confidential","public"]];if(!clientTypes.length||new Set(clientTypes).size!==clientTypes.length||clientTypes.some((type)=>type!=="confidential"&&type!=="public"))throw Error("auth.oauthProvider.clientTypes must contain unique supported client types.");return clientTypes}function resolvedConsent(options){const rememberFor=options?.rememberFor??0;if(!Number.isFinite(rememberFor)||rememberFor<0)throw Error("auth.oauthProvider.consent.rememberFor must be a non-negative number of milliseconds.");const view=options?.view??"auth/oauth/consent";if(!view||view.length>255||view.startsWith("/")||view.includes("..")||/[\u0000-\u001F\u007F]/.test(view))throw Error("auth.oauthProvider.consent.view must be a safe relative view name.");return{rememberFor,view}}export function resolveOAuthProviderConfig(options){if(options?.enabled!==!0)return null;const issuer=canonicalIssuer(options.issuer),resources=resolvedResources(options.resources),scopes=resolvedScopes(options.scopes,resources),lifetimes={authorizationCode:positiveLifetime("authorizationCode",options.lifetimes?.authorizationCode??DEFAULT_LIFETIMES.authorizationCode),accessToken:positiveLifetime("accessToken",options.lifetimes?.accessToken??DEFAULT_LIFETIMES.accessToken),refreshToken:positiveLifetime("refreshToken",options.lifetimes?.refreshToken??DEFAULT_LIFETIMES.refreshToken)};return{enabled:!0,issuer:issuer.toString().replace(/\/$/,""),endpoints:{authorization:endpoint(issuer,options.endpoints?.authorization,DEFAULT_ENDPOINTS.authorization),token:endpoint(issuer,options.endpoints?.token,DEFAULT_ENDPOINTS.token),revocation:endpoint(issuer,options.endpoints?.revocation,DEFAULT_ENDPOINTS.revocation),introspection:endpoint(issuer,options.endpoints?.introspection,DEFAULT_ENDPOINTS.introspection)},responseTypes:["code"],grantTypes:["authorization_code","refresh_token"],codeChallengeMethods:["S256"],clientTypes:resolvedClientTypes(options.clientTypes),scopes,resources,lifetimes,consent:resolvedConsent(options.consent)}}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Execute one RFC 7009 revocation request without registering a route. */
|
|
2
|
+
export declare function handleOAuthRevocationRequest(input: OAuthRevocationEndpointRequest): Promise<Response>;
|
|
3
|
+
export declare interface OAuthRevocationEndpointRequest {
|
|
4
|
+
body: string
|
|
5
|
+
contentType?: string | null
|
|
6
|
+
authorization?: string | null
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import{createHash}from"node:crypto";import{db,getDatabaseDialect,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";import{OAuthTokenRequestError,parseOAuthRevocationRequest}from"./oauth-token-request";import{oauthTokenErrorResponse}from"./oauth-token-response";import{revokeTokenPair}from"./token-revocation";const REVOCATION_HEADERS={"Cache-Control":"no-store",Pragma:"no-cache"};async function delegatedAccessTokenId(request,kind){if(!/^[a-f0-9]{80}$/.test(request.token))return null;const sql=sqlHelpers(getDatabaseDialect()),hash=createHash("sha256").update(request.token).digest("hex"),lock=sql.isSqlite?"":" FOR UPDATE",rows=kind==="access_token"?await db.unsafe(`
|
|
2
|
+
SELECT id FROM oauth_access_tokens
|
|
3
|
+
WHERE token = ${sql.param(1)} AND oauth_client_id = ${sql.param(2)}
|
|
4
|
+
AND oauth_grant_id IS NOT NULL
|
|
5
|
+
LIMIT 1${lock}
|
|
6
|
+
`,[hash,request.clientId]):await db.unsafe(`
|
|
7
|
+
SELECT a.id
|
|
8
|
+
FROM oauth_refresh_tokens r
|
|
9
|
+
JOIN oauth_access_tokens a ON a.id = r.access_token_id
|
|
10
|
+
WHERE r.token = ${sql.param(1)} AND a.oauth_client_id = ${sql.param(2)}
|
|
11
|
+
AND a.oauth_grant_id IS NOT NULL
|
|
12
|
+
LIMIT 1${lock}
|
|
13
|
+
`,[hash,request.clientId]),id=Number(rows[0]?.id);return Number.isSafeInteger(id)&&id>0?id:null}async function revokeOAuthClientToken(request){const order=request.tokenTypeHint==="refresh_token"?["refresh_token","access_token"]:["access_token","refresh_token"];for(const kind of order){const id=await delegatedAccessTokenId(request,kind);if(id==null)continue;await revokeTokenPair({id});return}}export async function handleOAuthRevocationRequest(input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??""),basicRealm:"oauth-revoke"};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthRevocationRequest(input.body,input.authorization);if(!await withAuthenticatedOAuthTokenClient(String(request.clientId),request.clientSecret,async()=>{await revokeOAuthClientToken(request);return!0}))return oauthTokenErrorResponse("invalid_client",responseOptions);return new Response(null,{status:200,headers:REVOCATION_HEADERS})}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
2
|
+
/** Execute one token request against the resolved provider without registering a route. */
|
|
3
|
+
export declare function handleOAuthTokenRequest(provider: ResolvedOAuthProviderConfig, input: OAuthTokenEndpointRequest): Promise<Response>;
|
|
4
|
+
export declare interface OAuthTokenEndpointRequest {
|
|
5
|
+
body: string
|
|
6
|
+
contentType?: string | null
|
|
7
|
+
authorization?: string | null
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{exchangeOAuthAuthorizationCode,refreshOAuthDelegatedToken}from"./oauth-token-exchange";import{OAuthTokenRequestError,parseOAuthTokenRequest}from"./oauth-token-request";import{oauthTokenErrorResponse,oauthTokenExchangeResponse}from"./oauth-token-response";export async function handleOAuthTokenRequest(provider,input){const responseOptions={clientAuthenticatedWithBasic:/^Basic\s/i.test(input.authorization??"")};if(input.contentType?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return oauthTokenErrorResponse("invalid_request",responseOptions);try{const request=parseOAuthTokenRequest(input.body,input.authorization);if(request.grantType==="authorization_code"){const result=await exchangeOAuthAuthorizationCode({code:request.code,clientId:request.clientId,clientSecret:request.clientSecret,redirectUri:request.redirectUri,codeVerifier:request.codeVerifier,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}const result=await refreshOAuthDelegatedToken({refreshToken:request.refreshToken,clientId:request.clientId,clientSecret:request.clientSecret,scopes:request.scopes,accessTokenLifetimeMs:provider.lifetimes.accessToken,refreshTokenLifetimeMs:provider.lifetimes.refreshToken});return oauthTokenExchangeResponse(result,responseOptions)}catch(error){if(error instanceof OAuthTokenRequestError)return oauthTokenErrorResponse(error.error,responseOptions);throw error}}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AuthorizationCodeGrant, AuthorizationCodeRedemption, AuthorizationCodeResult } from './oauth-authorization-codes';
|
|
2
|
+
/** Atomically consume an authorization code and mint its grant-bound token pair. */
|
|
3
|
+
export declare function exchangeAuthorizationCode(input: ExchangeAuthorizationCodeInput): Promise<AuthorizationCodeResult<DelegatedTokenPair>>;
|
|
4
|
+
/** Authenticate the provider client and exchange its code under one policy lock. */
|
|
5
|
+
export declare function exchangeOAuthAuthorizationCode(input: ExchangeOAuthAuthorizationCodeInput): Promise<OAuthAuthorizationCodeExchangeResult>;
|
|
6
|
+
/** Rotate one delegated refresh token and contain reuse to its token family. */
|
|
7
|
+
export declare function refreshOAuthDelegatedToken(input: RefreshOAuthDelegatedTokenInput): Promise<OAuthRefreshTokenExchangeResult>;
|
|
8
|
+
declare const invalidClient: {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: 'invalid_client'
|
|
11
|
+
};
|
|
12
|
+
declare const unauthorizedClient: {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: 'unauthorized_client'
|
|
15
|
+
};
|
|
16
|
+
declare const invalidScope: {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: 'invalid_scope'
|
|
19
|
+
};
|
|
20
|
+
export declare interface ExchangeAuthorizationCodeInput extends AuthorizationCodeRedemption {
|
|
21
|
+
code: string
|
|
22
|
+
accessTokenLifetimeMs: number
|
|
23
|
+
refreshTokenLifetimeMs: number
|
|
24
|
+
issueRefreshToken?: boolean
|
|
25
|
+
}
|
|
26
|
+
export declare interface ExchangeOAuthAuthorizationCodeInput extends ExchangeAuthorizationCodeInput {
|
|
27
|
+
clientSecret?: string
|
|
28
|
+
}
|
|
29
|
+
export declare interface RefreshOAuthDelegatedTokenInput {
|
|
30
|
+
refreshToken: string
|
|
31
|
+
clientId: number
|
|
32
|
+
clientSecret?: string
|
|
33
|
+
scopes?: readonly string[]
|
|
34
|
+
accessTokenLifetimeMs: number
|
|
35
|
+
refreshTokenLifetimeMs: number
|
|
36
|
+
}
|
|
37
|
+
export declare interface DelegatedTokenPair {
|
|
38
|
+
accessToken: string
|
|
39
|
+
refreshToken?: string
|
|
40
|
+
tokenType: 'Bearer'
|
|
41
|
+
expiresIn: number
|
|
42
|
+
grantId: string
|
|
43
|
+
clientId: number
|
|
44
|
+
subjectType: string
|
|
45
|
+
subjectId: number
|
|
46
|
+
scopes: string[]
|
|
47
|
+
resources: string[]
|
|
48
|
+
audiences: string[]
|
|
49
|
+
workspaceId: string | null
|
|
50
|
+
}
|
|
51
|
+
declare type DelegatedGrant = Omit<AuthorizationCodeGrant, 'redirectUri'>;
|
|
52
|
+
export type OAuthAuthorizationCodeExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
|
|
53
|
+
| typeof invalidClient;
|
|
54
|
+
export type OAuthRefreshTokenExchangeResult = AuthorizationCodeResult<DelegatedTokenPair>
|
|
55
|
+
| typeof invalidClient
|
|
56
|
+
| typeof invalidScope
|
|
57
|
+
| typeof unauthorizedClient;
|
|
58
|
+
declare class InactiveOAuthClientError extends Error {
|
|
59
|
+
|
|
60
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{withAuthorizationCode}from"./oauth-authorization-codes";import{withAuthenticatedOAuthTokenClient}from"./oauth-client-registration";class InactiveOAuthClientError extends Error{}const invalidGrant={ok:!1,reason:"invalid_grant"},invalidClient={ok:!1,reason:"invalid_client"},unauthorizedClient={ok:!1,reason:"unauthorized_client"},invalidScope={ok:!1,reason:"invalid_scope"};function tokenHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validLifetime(lifetimeMs){if(!Number.isSafeInteger(lifetimeMs)||lifetimeMs<=0)throw TypeError("OAuth token lifetime must be a positive safe integer.");return lifetimeMs}function deadline(issuedAt,lifetimeMs,mysql){const value=new Date(issuedAt.getTime()+lifetimeMs);if(!Number.isFinite(value.getTime()))throw TypeError("OAuth token lifetime is outside the supported date range.");if(mysql)value.setUTCMilliseconds(0);return value}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}function activeFlag(value){return value===!1||value===0}async function mintDelegatedTokenPair(grant,options){const sql=sqlHelpers(getDatabaseDialect()),accessToken=randomBytes(40).toString("hex"),refreshToken=options.issueRefreshToken?randomBytes(40).toString("hex"):void 0,refreshFamilyId=refreshToken?options.refreshFamilyId??randomBytes(16).toString("hex"):void 0;if(refreshFamilyId&&!/^[a-f0-9]{32}$/.test(refreshFamilyId))throw TypeError("OAuth refresh token family identifier is invalid.");const accessHash=tokenHash(accessToken),refreshHash=refreshToken?tokenHash(refreshToken):void 0,createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const accessExpiresAt=deadline(createdAt,options.accessLifetimeMs,sql.isMysql),refreshExpiresAt=options.issueRefreshToken?deadline(createdAt,options.refreshLifetimeMs,sql.isMysql):void 0,revoked=sql.isPostgres?!1:0,accessValues=sql.params(grant.subjectType,grant.subjectId,grant.subjectId,grant.clientId,accessHash,`oauth:${grant.grantId}`,JSON.stringify(grant.scopes),grant.grantId,JSON.stringify(grant.resources),JSON.stringify(grant.audiences),grant.workspaceId,revoked,sqlDateTime(accessExpiresAt),sqlDateTime(createdAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
2
|
+
INSERT INTO oauth_access_tokens (
|
|
3
|
+
tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes,
|
|
4
|
+
oauth_grant_id, resources, audiences, workspace_id, revoked, expires_at, created_at, updated_at
|
|
5
|
+
) VALUES (${accessValues.sql})
|
|
6
|
+
`,accessValues.values);const storedAccess=(await db.unsafe(`
|
|
7
|
+
SELECT id, tokenable_type, tokenable_id, oauth_client_id, token, scopes, oauth_grant_id,
|
|
8
|
+
resources, audiences, workspace_id, revoked, expires_at
|
|
9
|
+
FROM oauth_access_tokens WHERE token = ${sql.param(1)} LIMIT 1
|
|
10
|
+
`,[accessHash]))[0];if(!storedAccess||storedAccess.tokenable_type!==grant.subjectType||String(storedAccess.tokenable_id)!==String(grant.subjectId)||String(storedAccess.oauth_client_id)!==String(grant.clientId)||storedAccess.oauth_grant_id!==grant.grantId||JSON.stringify(storedValues(storedAccess.scopes))!==JSON.stringify(grant.scopes)||JSON.stringify(storedValues(storedAccess.resources))!==JSON.stringify(grant.resources)||JSON.stringify(storedValues(storedAccess.audiences))!==JSON.stringify(grant.audiences)||storedAccess.workspace_id!==grant.workspaceId||!activeFlag(storedAccess.revoked)||parseSqlDateTime(storedAccess.expires_at)?.getTime()!==accessExpiresAt.getTime())throw Error("Failed to persist the delegated access token.");if(refreshToken&&refreshHash&&refreshExpiresAt){const refreshValues=sql.params(Number(storedAccess.id),refreshHash,refreshFamilyId,options.refreshParentId??null,revoked,sqlDateTime(refreshExpiresAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
11
|
+
INSERT INTO oauth_refresh_tokens (access_token_id, token, family_id, parent_id, revoked, expires_at, created_at)
|
|
12
|
+
VALUES (${refreshValues.sql})
|
|
13
|
+
`,refreshValues.values);const storedRefresh=(await db.unsafe(`
|
|
14
|
+
SELECT id, access_token_id, token, family_id, parent_id, revoked, expires_at
|
|
15
|
+
FROM oauth_refresh_tokens WHERE token = ${sql.param(1)} LIMIT 1
|
|
16
|
+
`,[refreshHash]))[0];if(!storedRefresh||String(storedRefresh.access_token_id)!==String(storedAccess.id)||storedRefresh.family_id!==refreshFamilyId||String(storedRefresh.parent_id??"")!==String(options.refreshParentId??"")||!activeFlag(storedRefresh.revoked)||parseSqlDateTime(storedRefresh.expires_at)?.getTime()!==refreshExpiresAt.getTime())throw Error("Failed to persist the delegated refresh token.")}return{accessToken,...refreshToken?{refreshToken}:{},tokenType:"Bearer",expiresIn:Math.max(0,Math.floor((accessExpiresAt.getTime()-createdAt.getTime())/1000)),grantId:grant.grantId,clientId:grant.clientId,subjectType:grant.subjectType,subjectId:grant.subjectId,scopes:grant.scopes,resources:grant.resources,audiences:grant.audiences,workspaceId:grant.workspaceId}}function grantFromRefreshRows(refresh,stored){const clientId=Number(refresh.oauth_client_id),subjectId=Number(refresh.tokenable_id),scopes=storedValues(refresh.scopes),resources=storedValues(refresh.resources),audiences=storedValues(refresh.audiences),grantScopes=storedValues(stored?.grant_scopes??null),grantResources=storedValues(stored?.grant_resources??null),grantAudiences=storedValues(stored?.grant_audiences??null);if(!stored||stored.grant_revoked_at!=null||!refresh.oauth_grant_id||!/^[a-f0-9]{32}$/.test(refresh.oauth_grant_id)||!Number.isSafeInteger(clientId)||clientId<=0||!Number.isSafeInteger(subjectId)||subjectId<=0||!scopes?.length||new Set(scopes).size!==scopes.length||!resources||!audiences||!grantScopes||!grantResources||!grantAudiences||String(stored.grant_client_id)!==String(clientId)||stored.grant_subject_type!==refresh.tokenable_type||String(stored.grant_subject_id)!==String(subjectId)||!scopes.every((scope)=>grantScopes.includes(scope))||JSON.stringify(grantResources)!==JSON.stringify(resources)||JSON.stringify(grantAudiences)!==JSON.stringify(audiences)||stored.grant_workspace_id!==refresh.workspace_id)return null;return{grantId:refresh.oauth_grant_id,clientId,subjectType:refresh.tokenable_type,subjectId,scopes,resources,audiences,workspaceId:refresh.workspace_id}}async function revokeDelegatedRefreshFamily(refresh){const sql=sqlHelpers(getDatabaseDialect()),revoked=sql.isPostgres?!0:1;if(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id)){await db.unsafe(`
|
|
17
|
+
UPDATE oauth_access_tokens
|
|
18
|
+
SET revoked = ${sql.param(1)}
|
|
19
|
+
WHERE id IN (
|
|
20
|
+
SELECT access_token_id FROM oauth_refresh_tokens WHERE family_id = ${sql.param(2)}
|
|
21
|
+
)
|
|
22
|
+
`,[revoked,refresh.family_id]);await db.unsafe(`
|
|
23
|
+
UPDATE oauth_refresh_tokens
|
|
24
|
+
SET revoked = ${sql.param(1)}
|
|
25
|
+
WHERE family_id = ${sql.param(2)}
|
|
26
|
+
`,[revoked,refresh.family_id]);return}await db.unsafe(`UPDATE oauth_access_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.access_token_id]);await db.unsafe(`UPDATE oauth_refresh_tokens SET revoked = ${sql.param(1)} WHERE id = ${sql.param(2)}`,[revoked,refresh.id])}export async function exchangeAuthorizationCode(input){const sql=sqlHelpers(getDatabaseDialect()),accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),issueRefreshToken=input.issueRefreshToken!==!1,refreshLifetimeMs=issueRefreshToken?validLifetime(input.refreshTokenLifetimeMs):0;try{return await withAuthorizationCode(input.code,input,async(grant)=>{const clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"";if((await db.unsafe(`
|
|
27
|
+
SELECT id FROM oauth_clients
|
|
28
|
+
WHERE id = ${sql.param(1)} AND revoked = ${sql.boolFalse}
|
|
29
|
+
LIMIT 1${clientLock}
|
|
30
|
+
`,[grant.clientId])).length!==1)throw new InactiveOAuthClientError;return mintDelegatedTokenPair(grant,{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken})})}catch(error){if(error instanceof InactiveOAuthClientError)return invalidGrant;throw error}}export async function exchangeOAuthAuthorizationCode(input){const{clientSecret,...exchange}=input;return await withAuthenticatedOAuthTokenClient(String(input.clientId),clientSecret,async(client)=>exchangeAuthorizationCode({...exchange,issueRefreshToken:client.grantTypes.includes("refresh_token")}))??invalidClient}export async function refreshOAuthDelegatedToken(input){const accessLifetimeMs=validLifetime(input.accessTokenLifetimeMs),refreshLifetimeMs=validLifetime(input.refreshTokenLifetimeMs),authenticated=await withAuthenticatedOAuthTokenClient(String(input.clientId),input.clientSecret,async(client)=>{if(!client.grantTypes.includes("refresh_token"))return{result:unauthorizedClient,wrote:!1};if(!/^[a-f0-9]{80}$/.test(input.refreshToken))return{result:invalidGrant,wrote:!1};const sql=sqlHelpers(getDatabaseDialect()),hash=tokenHash(input.refreshToken),lock=sql.isSqlite?"":" FOR UPDATE",candidate=(await db.unsafe(`
|
|
31
|
+
SELECT r.id, r.family_id, a.oauth_client_id
|
|
32
|
+
FROM oauth_refresh_tokens r
|
|
33
|
+
JOIN oauth_access_tokens a ON a.id = r.access_token_id
|
|
34
|
+
WHERE r.token = ${sql.param(1)}
|
|
35
|
+
LIMIT 1
|
|
36
|
+
`,[hash]))[0];if(!candidate||String(candidate.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};if(candidate.family_id&&/^[a-f0-9]{32}$/.test(candidate.family_id))await db.unsafe(`
|
|
37
|
+
SELECT id FROM oauth_refresh_tokens
|
|
38
|
+
WHERE family_id = ${sql.param(1)}
|
|
39
|
+
ORDER BY id${lock}
|
|
40
|
+
`,[candidate.family_id]);const refresh=(await db.unsafe(`
|
|
41
|
+
SELECT r.id, r.access_token_id, r.token, r.family_id, r.parent_id, r.revoked,
|
|
42
|
+
r.expires_at, r.created_at, a.tokenable_type, a.tokenable_id,
|
|
43
|
+
a.oauth_client_id, a.oauth_grant_id, a.scopes, a.resources, a.audiences,
|
|
44
|
+
a.workspace_id, a.revoked AS access_revoked
|
|
45
|
+
FROM oauth_refresh_tokens r
|
|
46
|
+
JOIN oauth_access_tokens a ON a.id = r.access_token_id
|
|
47
|
+
WHERE r.id = ${sql.param(1)} AND r.token = ${sql.param(2)}
|
|
48
|
+
LIMIT 1${lock}
|
|
49
|
+
`,[candidate.id,hash]))[0];if(!refresh||String(refresh.oauth_client_id)!==String(input.clientId))return{result:invalidGrant,wrote:!1};const familyIsValid=Boolean(refresh.family_id&&/^[a-f0-9]{32}$/.test(refresh.family_id));if(!activeFlag(refresh.revoked)||!activeFlag(refresh.access_revoked)||!familyIsValid){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}if((parseSqlDateTime(refresh.expires_at)?.getTime()??0)<=Date.now())return{result:invalidGrant,wrote:!1};const grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grants=await db.unsafe(`
|
|
50
|
+
SELECT id, client_id AS grant_client_id, subject_type AS grant_subject_type,
|
|
51
|
+
subject_id AS grant_subject_id, scopes AS grant_scopes,
|
|
52
|
+
resources AS grant_resources, audiences AS grant_audiences,
|
|
53
|
+
workspace_id AS grant_workspace_id, revoked_at AS grant_revoked_at
|
|
54
|
+
FROM oauth_grants
|
|
55
|
+
WHERE id = ${sql.param(1)}
|
|
56
|
+
LIMIT 1${grantLock}
|
|
57
|
+
`,[refresh.oauth_grant_id]),grant=grantFromRefreshRows(refresh,grants[0]);if(!grant){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}const scopes=input.scopes?[...input.scopes]:grant.scopes;if(!scopes.length||new Set(scopes).size!==scopes.length||!scopes.every((scope)=>grant.scopes.includes(scope)&&client.scopes.includes(scope)))return{result:invalidScope,wrote:!1};const revoked=sql.isPostgres?!0:1,refreshClaim=await db.unsafe(`
|
|
58
|
+
UPDATE oauth_refresh_tokens
|
|
59
|
+
SET revoked = ${sql.param(1)}
|
|
60
|
+
WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
|
|
61
|
+
`,[revoked,refresh.id,sql.isPostgres?!1:0]),accessClaim=await db.unsafe(`
|
|
62
|
+
UPDATE oauth_access_tokens
|
|
63
|
+
SET revoked = ${sql.param(1)}
|
|
64
|
+
WHERE id = ${sql.param(2)} AND revoked = ${sql.param(3)}
|
|
65
|
+
`,[revoked,refresh.access_token_id,sql.isPostgres?!1:0]);if(mutationCount(refreshClaim)!==1||mutationCount(accessClaim)!==1){await revokeDelegatedRefreshFamily(refresh);return{result:invalidGrant,wrote:!0}}return{result:{ok:!0,value:await mintDelegatedTokenPair({...grant,scopes},{accessLifetimeMs,refreshLifetimeMs,issueRefreshToken:!0,refreshFamilyId:refresh.family_id,refreshParentId:Number(refresh.id)})},wrote:!0}});if(!authenticated)return invalidClient;if(authenticated.wrote)markContextWrote();return authenticated.result}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Parse one form-encoded token request without accepting mixed client authentication methods. */
|
|
2
|
+
export declare function parseOAuthTokenRequest(body: string, authorization?: string | null): OAuthTokenRequest;
|
|
3
|
+
/** Parse one RFC 7009 revocation request through the token endpoint's client-authentication rules. */
|
|
4
|
+
export declare function parseOAuthRevocationRequest(body: string, authorization?: string | null): OAuthRevocationRequest;
|
|
5
|
+
export declare interface OAuthClientCredentials {
|
|
6
|
+
clientId: number
|
|
7
|
+
clientSecret?: string
|
|
8
|
+
}
|
|
9
|
+
export declare interface OAuthAuthorizationCodeTokenRequest {
|
|
10
|
+
grantType: 'authorization_code'
|
|
11
|
+
clientId: number
|
|
12
|
+
clientSecret?: string
|
|
13
|
+
code: string
|
|
14
|
+
redirectUri: string
|
|
15
|
+
codeVerifier: string
|
|
16
|
+
}
|
|
17
|
+
export declare interface OAuthRefreshTokenRequest {
|
|
18
|
+
grantType: 'refresh_token'
|
|
19
|
+
clientId: number
|
|
20
|
+
clientSecret?: string
|
|
21
|
+
refreshToken: string
|
|
22
|
+
scopes?: string[]
|
|
23
|
+
}
|
|
24
|
+
export declare interface OAuthRevocationRequest extends OAuthClientCredentials {
|
|
25
|
+
token: string
|
|
26
|
+
tokenTypeHint?: 'access_token' | 'refresh_token'
|
|
27
|
+
}
|
|
28
|
+
export type OAuthTokenRequestErrorCode = | 'invalid_client'
|
|
29
|
+
| 'invalid_request'
|
|
30
|
+
| 'unsupported_grant_type'
|
|
31
|
+
| 'unsupported_token_type';
|
|
32
|
+
export type OAuthTokenRequest = OAuthAuthorizationCodeTokenRequest | OAuthRefreshTokenRequest;
|
|
33
|
+
export declare class OAuthTokenRequestError extends Error {
|
|
34
|
+
public readonly error: OAuthTokenRequestErrorCode;
|
|
35
|
+
constructor(error: OAuthTokenRequestErrorCode, message: string);
|
|
36
|
+
}
|
|
@@ -0,0 +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 refresh 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}:{}}}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}:{}}}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OAuthAuthorizationCodeExchangeResult, OAuthRefreshTokenExchangeResult } from './oauth-token-exchange';
|
|
2
|
+
import type { OAuthTokenRequestErrorCode } from './oauth-token-request';
|
|
3
|
+
/** Return one OAuth error without exposing authentication or grant details. */
|
|
4
|
+
export declare function oauthTokenErrorResponse(error: OAuthTokenProtocolErrorCode, options?: OAuthTokenResponseOptions): Response;
|
|
5
|
+
/** Serialize only the public OAuth token fields and apply mandatory no-store headers. */
|
|
6
|
+
export declare function oauthTokenExchangeResponse(result: OAuthAuthorizationCodeExchangeResult | OAuthRefreshTokenExchangeResult, options?: OAuthTokenResponseOptions): Response;
|
|
7
|
+
export declare interface OAuthTokenResponseOptions {
|
|
8
|
+
clientAuthenticatedWithBasic?: boolean
|
|
9
|
+
basicRealm?: string
|
|
10
|
+
}
|
|
11
|
+
export type OAuthTokenProtocolErrorCode = OAuthTokenRequestErrorCode
|
|
12
|
+
| 'invalid_grant'
|
|
13
|
+
| 'invalid_scope'
|
|
14
|
+
| 'unauthorized_client';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const TOKEN_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache"};export function oauthTokenErrorResponse(error,options={}){const challenge=error==="invalid_client"&&options.clientAuthenticatedWithBasic;return new Response(JSON.stringify({error}),{status:challenge?401:400,headers:{...TOKEN_HEADERS,...challenge?{"WWW-Authenticate":`Basic realm="${options.basicRealm??"oauth-token"}"`}:{}}})}export function oauthTokenExchangeResponse(result,options={}){if(!result.ok)return oauthTokenErrorResponse(result.reason,options);const token=result.value;return new Response(JSON.stringify({access_token:token.accessToken,token_type:token.tokenType,expires_in:token.expiresIn,...token.refreshToken?{refresh_token:token.refreshToken}:{},scope:token.scopes.join(" ")}),{status:200,headers:TOKEN_HEADERS})}
|
package/dist/tokens.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{tokenDate,tokenTimestamps}from"./token-dates";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";function tokenSql(){return sqlHelpers(getDatabaseDialect())}function appNow(){return sqlDateTimeLiteral()}function validTokenExpiry(value){if(value==null)return!0;const expiry=parseSqlDateTime(value);return expiry!=null&&expiry.getTime()>Date.now()}async function verifyRefreshTokenInsert(trx,hash,accessTokenId,expiresAt){const{param}=tokenSql(),rows=await trx.unsafe(`
|
|
2
2
|
SELECT access_token_id, revoked, expires_at FROM oauth_refresh_tokens WHERE token = ${param(1)}
|
|
3
|
-
`,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date;deadline.setDate(deadline.getDate()+days);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
|
|
3
|
+
`,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date;deadline.setDate(deadline.getDate()+days);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getPasswordChangedAt(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return null;const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`;try{if(isMysql){if((await q.unsafe(`
|
|
4
4
|
SELECT 1 FROM information_schema.columns
|
|
5
5
|
WHERE table_schema = DATABASE() AND table_name = ?
|
|
6
6
|
AND column_name = 'password_changed_at'
|
|
@@ -18,13 +18,26 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
18
18
|
AND t.tokenable_type = ${param(2)}
|
|
19
19
|
AND t.revoked = ${boolFalse}
|
|
20
20
|
ORDER BY t.created_at DESC
|
|
21
|
-
`,[userId,tokenableType])).map((row)=>({id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at==null?null:tokenDate(row.expires_at),...tokenTimestamps(row),userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const{boolFalse,param}=tokenSql(),hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
|
|
22
|
-
SELECT
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
`,[userId,tokenableType])).map((row)=>({id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:row.expires_at==null?null:tokenDate(row.expires_at),...tokenTimestamps(row),userAgent:row.user_agent??null,ipAddress:row.ip_address??null}))}export async function findToken(plainTextToken){const{boolFalse,param}=tokenSql(),hashedToken=bearerLookupHash(plainTextToken),row=(await db.unsafe(`
|
|
22
|
+
SELECT t.*, g.scopes AS grant_scopes FROM oauth_access_tokens t
|
|
23
|
+
LEFT JOIN oauth_grants g ON g.id = t.oauth_grant_id
|
|
24
|
+
LEFT JOIN oauth_clients c ON c.id = g.client_id
|
|
25
|
+
WHERE t.token = ${param(1)}
|
|
26
|
+
AND t.revoked = ${boolFalse}
|
|
27
|
+
AND (t.expires_at IS NULL OR t.expires_at > ${appNow()})
|
|
28
|
+
AND (t.oauth_grant_id IS NULL OR (
|
|
29
|
+
g.id IS NOT NULL
|
|
30
|
+
AND c.revoked = ${boolFalse}
|
|
31
|
+
AND g.revoked_at IS NULL
|
|
32
|
+
AND g.client_id = t.oauth_client_id
|
|
33
|
+
AND g.subject_type = t.tokenable_type
|
|
34
|
+
AND g.subject_id = t.tokenable_id
|
|
35
|
+
AND g.resources = t.resources
|
|
36
|
+
AND g.audiences = t.audiences
|
|
37
|
+
AND (g.workspace_id = t.workspace_id OR (g.workspace_id IS NULL AND t.workspace_id IS NULL))
|
|
38
|
+
))
|
|
26
39
|
LIMIT 1
|
|
27
|
-
`,[hashedToken]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
|
|
40
|
+
`,[hashedToken]))[0];if(!row||!validTokenExpiry(row.expires_at))return null;if(row.oauth_grant_id){const scopes=parseScopes(row.scopes),grantScopes=parseScopes(row.grant_scopes);if(!scopes.length||new Set(scopes).size!==scopes.length||scopes.some((scope)=>typeof scope!=="string"||!grantScopes.includes(scope)))return null}if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(row.tokenable_id??row.user_id,db,row.tokenable_type??DEFAULT_TOKENABLE_TYPE)))return null;return{id:Number(row.id),userId:Number(row.user_id),subjectType:row.tokenable_type,subjectId:Number(row.tokenable_id),clientId:Number(row.oauth_client_id),...delegatedContext(row),name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!!row.revoked,expiresAt:parseSqlDateTime(row.expires_at),...tokenTimestamps(row)}}export async function currentAccessToken(){const request=getCurrentRequest();if(!request)return null;const attached=request._currentAccessToken;if(attached)return!attached.revoked&&validTokenExpiry(attached.expiresAt)?attached:null;const bearerToken=requestToken(request);if(!bearerToken)return null;const token=await findToken(bearerToken);if(token)request._currentAccessToken=token;return token}export const token=currentAccessToken;export async function tokenCan(scope){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return accessToken.scopes.includes(scope)}export async function tokenCant(scope){return!await tokenCan(scope)}export async function tokenCanAll(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.every((scope)=>accessToken.scopes.includes(scope))}export async function tokenCanAny(scopes){const accessToken=await currentAccessToken();if(!accessToken)return!1;if(accessToken.scopes.includes("*"))return!0;return scopes.some((scope)=>accessToken.scopes.includes(scope))}export async function tokenAbilities(){return(await currentAccessToken())?.scopes||[]}export async function createToken(userId,name="access-token",scopes=["*"],options={}){const{isMysql,isPostgres,boolTrue,boolFalse,param}=tokenSql(),{expiresInMinutes=60,withRefreshToken=!0,refreshExpiresInDays=30,userAgent=null,ipAddress=null,tokenableType=DEFAULT_TOKENABLE_TYPE}=options,issuedAt=Date.now(),expiresAt=new Date(options.expiresAt?.getTime()??issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);const result=await db.transaction(async(trx)=>{const client=(await trx.unsafe(`
|
|
28
41
|
SELECT id FROM oauth_clients
|
|
29
42
|
WHERE ${options.clientId===void 0?`personal_access_client = ${boolTrue}`:`id = ${param(1)}`}
|
|
30
43
|
AND revoked = ${boolFalse} LIMIT 1
|
|
@@ -43,7 +56,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
43
56
|
INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
|
|
44
57
|
VALUES (?, ?, 0, ?, ${appNow()})
|
|
45
58
|
`,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);await verifyRefreshTokenInsert(trx,hashedRefreshToken,accessToken.id,refreshExpiresAt)}return{accessToken,plainTextToken,refreshToken:refreshTokenPlain,expiresIn:Math.max(0,Math.floor((expiresAt.getTime()-issuedAt)/1000))}});markContextWrote();return result}export async function refreshToken(refreshTokenPlain,options={}){const{isMysql,isPostgres,boolFalse,param}=tokenSql(),{expiresInMinutes=60,refreshExpiresInDays=30}=options,hashedRefreshToken=hashToken(refreshTokenPlain),result=await db.transaction(async(rawTrx)=>{const trx=rawTrx;let selector=`r.token = ${param(1)}`,bindings=[hashedRefreshToken];if(isMysql){const candidate=(await trx.unsafe("SELECT id FROM oauth_refresh_tokens WHERE token = ? LIMIT 1",[hashedRefreshToken]))[0];if(!candidate)throw new HttpError(401,"Invalid or expired refresh token");selector="r.id = ? AND r.token = ?";bindings=[candidate.id,hashedRefreshToken]}const forUpdate=isPostgres||isMysql?" FOR UPDATE":"",refreshRow=(await trx.unsafe(`
|
|
46
|
-
SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.name, t.scopes
|
|
59
|
+
SELECT r.*, t.tokenable_type, t.tokenable_id, t.oauth_client_id, t.oauth_grant_id, t.name, t.scopes
|
|
47
60
|
FROM oauth_refresh_tokens r
|
|
48
61
|
JOIN oauth_access_tokens t ON r.access_token_id = t.id
|
|
49
62
|
WHERE ${selector}
|
|
@@ -54,7 +67,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
54
67
|
)
|
|
55
68
|
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
56
69
|
LIMIT 1${forUpdate}
|
|
57
|
-
`,bindings))[0];if(!refreshRow||refreshRow.access_token_id==null||!validTokenExpiry(refreshRow.expires_at))throw new HttpError(401,"Invalid or expired refresh token");const clientLock=isPostgres?" FOR SHARE":isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
|
|
70
|
+
`,bindings))[0];if(!refreshRow||refreshRow.access_token_id==null||!validTokenExpiry(refreshRow.expires_at))throw new HttpError(401,"Invalid or expired refresh token");if(refreshRow.oauth_grant_id!=null)throw new HttpError(401,"Invalid or expired refresh token");const clientLock=isPostgres?" FOR SHARE":isMysql?" LOCK IN SHARE MODE":"";if((await trx.unsafe(`
|
|
58
71
|
SELECT id FROM oauth_clients WHERE id = ${param(1)} AND revoked = ${boolFalse}${clientLock}
|
|
59
72
|
`,[refreshRow.oauth_client_id])).length!==1)throw new HttpError(401,"Invalid or expired refresh token");if(isIssuedBeforePasswordChange(refreshRow.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");await revokeTokenPair({id:refreshRow.access_token_id});const plainTextToken=generateSecureToken(40),hashedToken=hashToken(plainTextToken),issuedAt=Date.now(),expiresAt=new Date(issuedAt+expiresInMinutes*60000);if(isMysql)expiresAt.setUTCMilliseconds(0);if(isPostgres)await trx.unsafe(`
|
|
60
73
|
INSERT INTO oauth_access_tokens (tokenable_type, tokenable_id, user_id, oauth_client_id, token, name, scopes, revoked, expires_at, created_at, updated_at)
|
|
@@ -79,6 +92,7 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
79
92
|
AND EXISTS (
|
|
80
93
|
SELECT 1 FROM oauth_clients c WHERE c.id = t.oauth_client_id AND c.revoked = ${boolFalse}
|
|
81
94
|
)
|
|
95
|
+
AND t.oauth_grant_id IS NULL
|
|
82
96
|
AND (r.expires_at IS NULL OR r.expires_at > ${appNow()})
|
|
83
97
|
LIMIT 1
|
|
84
98
|
`,[hashedRefreshToken]))[0];return row!=null&&validTokenExpiry(row.expires_at)}export async function revokeRefreshToken(refreshTokenPlain){const{boolTrue,param}=tokenSql(),hashedRefreshToken=hashToken(refreshTokenPlain);await db.unsafe(`
|
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.16",
|
|
6
6
|
"description": "A more simplistic way to authenticate.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -58,18 +58,18 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"@stacksjs/bun-router": "^0.1.18",
|
|
61
|
-
"@stacksjs/cache": "0.75.
|
|
62
|
-
"@stacksjs/config": "0.75.
|
|
63
|
-
"@stacksjs/database": "0.75.
|
|
64
|
-
"@stacksjs/email": "0.75.
|
|
65
|
-
"@stacksjs/env": "0.75.
|
|
66
|
-
"@stacksjs/error-handling": "0.75.
|
|
67
|
-
"@stacksjs/logging": "0.75.
|
|
68
|
-
"@stacksjs/orm": "0.75.
|
|
69
|
-
"@stacksjs/path": "0.75.
|
|
70
|
-
"@stacksjs/router": "0.75.
|
|
71
|
-
"@stacksjs/security": "0.75.
|
|
72
|
-
"@stacksjs/storage": "0.75.
|
|
61
|
+
"@stacksjs/cache": "0.75.16",
|
|
62
|
+
"@stacksjs/config": "0.75.16",
|
|
63
|
+
"@stacksjs/database": "0.75.16",
|
|
64
|
+
"@stacksjs/email": "0.75.16",
|
|
65
|
+
"@stacksjs/env": "0.75.16",
|
|
66
|
+
"@stacksjs/error-handling": "0.75.16",
|
|
67
|
+
"@stacksjs/logging": "0.75.16",
|
|
68
|
+
"@stacksjs/orm": "0.75.16",
|
|
69
|
+
"@stacksjs/path": "0.75.16",
|
|
70
|
+
"@stacksjs/router": "0.75.16",
|
|
71
|
+
"@stacksjs/security": "0.75.16",
|
|
72
|
+
"@stacksjs/storage": "0.75.16",
|
|
73
73
|
"@stacksjs/ts-auth": "^0.4.4",
|
|
74
74
|
"ts-qr-codes": "^0.1.8"
|
|
75
75
|
},
|