@stacksjs/auth 0.75.28 → 0.75.30
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 +34 -17
- package/dist/authentication.js +1 -1
- package/dist/credential-user.d.ts +5 -0
- package/dist/credential-user.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -1
- package/dist/oauth-authorization-codes.js +1 -1
- package/dist/oauth-authorization-endpoint.d.ts +10 -1
- package/dist/oauth-authorization-endpoint.js +1 -1
- package/dist/oauth-authorization-requests.d.ts +11 -2
- package/dist/oauth-authorization-requests.js +20 -6
- package/dist/oauth-authorization.d.ts +2 -0
- package/dist/oauth-authorization.js +1 -1
- package/dist/oauth-client-policy.d.ts +18 -0
- package/dist/oauth-client-policy.js +1 -0
- package/dist/oauth-client-registration.js +10 -10
- package/dist/oauth-consent.d.ts +15 -0
- package/dist/oauth-consent.js +4 -4
- package/dist/oauth-grants.d.ts +2 -0
- package/dist/oauth-grants.js +27 -5
- package/dist/oauth-metadata.d.ts +2 -0
- package/dist/oauth-metadata.js +1 -1
- package/dist/oauth-provider.d.ts +4 -2
- package/dist/oauth-provider.js +1 -1
- package/dist/oauth-pruning.d.ts +10 -0
- package/dist/oauth-pruning.js +9 -0
- package/dist/oauth-token-endpoint.d.ts +5 -1
- package/dist/oauth-token-endpoint.js +1 -1
- package/dist/oauth-token-exchange.d.ts +4 -0
- package/dist/oauth-token-exchange.js +11 -5
- package/dist/password/reset.js +2 -2
- package/dist/rate-limiter.d.ts +15 -10
- package/dist/rate-limiter.js +26 -1
- package/dist/register.js +1 -1
- package/dist/session-auth.js +1 -1
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -30,23 +30,40 @@ Learn more in the docs.
|
|
|
30
30
|
|
|
31
31
|
## OAuth authorization provider
|
|
32
32
|
|
|
33
|
-
The authorization provider is
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
resources
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
The authorization provider is disabled by default under `oauthProvider` in
|
|
34
|
+
`config/auth.ts`. Enabling it requires a canonical issuer plus explicit scope
|
|
35
|
+
and resource policy:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
oauthProvider: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
issuer: env.APP_URL,
|
|
41
|
+
scopes: {
|
|
42
|
+
'issues:read': { description: 'Read issues', resources: ['bughq'] },
|
|
43
|
+
},
|
|
44
|
+
resources: {
|
|
45
|
+
bughq: { audience: 'https://api.example.com/issues' },
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The default auth route bundle then serves:
|
|
51
|
+
|
|
52
|
+
- `GET` and `POST /oauth/authorize` for login, consent, denial, and S256 PKCE
|
|
53
|
+
- `POST /oauth/token` for authorization-code exchange and rotating refresh tokens
|
|
54
|
+
- `POST /oauth/revoke` for client-owned access and refresh token revocation
|
|
55
|
+
- `GET /.well-known/oauth-authorization-server` for RFC 8414 metadata
|
|
56
|
+
- authenticated client registration, editing, secret rotation, and disable routes under `/auth/oauth/clients`
|
|
57
|
+
- authenticated connected-application listing and disconnect routes under `/auth/oauth/connections`
|
|
58
|
+
|
|
59
|
+
The implementation stores only hashes for codes, access tokens, refresh
|
|
60
|
+
tokens, browser authorization state, and confidential client secrets. Client
|
|
61
|
+
redirects use exact matching. Consent and issued credentials remain bound to
|
|
62
|
+
the client, subject, scopes, resources, audiences, and optional workspace.
|
|
63
|
+
|
|
64
|
+
This profile does not implement client credentials, token introspection, or
|
|
65
|
+
OpenID Connect. Do not configure or advertise those capabilities until their
|
|
66
|
+
authentication and authorization policies are implemented.
|
|
50
67
|
|
|
51
68
|
## 🧪 Testing
|
|
52
69
|
|
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";import{schedulePasswordRehash}from"./password-rehash";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}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 verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",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,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",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 verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??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}}}
|
|
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{findAuthUserByEmail,normalizeAuthEmail}from"./credential-user";import{RateLimiter}from"./rate-limiter";import{withVerifiedPassword}from"./credential-version";import{tokenDate,tokenTimestamps}from"./token-dates";import{schedulePasswordRehash}from"./password-rehash";const REQUEST_AUTH_STATE_KEY=Symbol.for("stacks.requestAuthState");function authStateOrNull(){const req=getCurrentRequest();if(!req)return null;let state=req[REQUEST_AUTH_STATE_KEY];if(!state){state={};req[REQUEST_AUTH_STATE_KEY]=state}return state}function hashToken(token){return createHash("sha256").update(token).digest("hex")}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 verified=await this.verifyCredentials(credentials);if(!verified)return!1;schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);const state=authStateOrNull();if(state)state.authUser=verified.user;return!0}static async verifyCredentials(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return null;const email=normalizeAuthEmail(rawEmail),isRateLimited=await RateLimiter.isRateLimited(email),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH,hashCheck=await verifyHash(authPass,hashToVerify);if(isRateLimited)return null;if(hashCheck&&user){await RateLimiter.resetAttempts(email);return{user,plaintext:authPass,hash:hashToVerify}}await RateLimiter.recordFailedAttempt(email);return null}static async validate(credentials){const username=config.auth.username||"email",password=config.auth.password||"password",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;return await verifyHash(authPass,hashToVerify)&&!!user}static async withVerifiedCredentials(credentials,issue){const verified=await this.verifyCredentials(credentials);if(!verified)return null;const result=await withVerifiedPassword(verified.user.id,verified.hash,()=>issue(verified.user));if(result!==null)schedulePasswordRehash(verified.user.id,verified.plaintext,verified.hash);return result}static async login(credentials,options){const result=await this.withVerifiedCredentials(credentials,async(user)=>{const{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);return{user,token:plainTextToken,refreshToken,expiresIn}});if(!result)return null;const state=authStateOrNull();if(state)state.authUser=result.user;return result}static async loginUsingId(userId,options){let user;if(isInTransaction()){const definition=User.getDefinition();let query=db.primary.selectFrom(definition.table||"users").where(definition.primaryKey||"id","=",userId).selectAll();if(definition.traits?.useSoftDeletes)query=query.whereNull("deleted_at");const row=await query.executeTakeFirst();user=row?await User.make(row):null}else user=await User.find(userId);if(!user)return null;const state=authStateOrNull(),{plainTextToken,refreshToken,expiresIn}=await this.createTokenForUser(user,options);if(state)state.authUser=user;return{user,token:plainTextToken,refreshToken,expiresIn}}static async logout(){const bearerToken=this.getBearerToken();if(bearerToken)await this.revokeToken(bearerToken);else{const sessionId=getCurrentRequest()?.cookie?.("session_id");if(sessionId)await sessionLogout(sessionId)}const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0}const currentRequest=getCurrentRequest();if(currentRequest){currentRequest._authenticatedUser=void 0;currentRequest._currentAccessToken=void 0}}static async user(){const state=authStateOrNull();if(state?.authUser)return state.authUser;const bearerToken=this.getBearerToken(),sessionId=!bearerToken?getCurrentRequest()?.cookie?.("session_id"):void 0,user=bearerToken?await this.getUserFromToken(bearerToken):sessionId?await sessionUser(sessionId):void 0;if(user&&state)state.authUser=user;return user}static async check(){return await this.user()!==void 0}static async guest(){return!await this.check()}static async id(){return(await this.user())?.id}static setUser(user){const state=authStateOrNull();if(state)state.authUser=user}static async createTokenForUser(user,options){const name=options?.name??config.auth.defaultTokenName??"auth-token",abilities=options?.abilities??options?.scopes??config.auth.defaultAbilities??["*"],accessTtlMs=options?.expiresInMinutes!==void 0?options.expiresInMinutes*60*1000:config.auth.tokenExpiry??3600000,expiresAt=new Date(options?.expiresAt?.getTime()??Date.now()+accessTtlMs),refreshExpiresInDays=options?.refreshExpiresInDays??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",rawEmail=credentials[username];if(!rawEmail)return!1;const email=normalizeAuthEmail(rawEmail),user=await findAuthUserByEmail(email),authPass=credentials[password]||"",hashToVerify=user?.password||DUMMY_BCRYPT_HASH;if(await verifyHash(authPass,hashToVerify)&&user){const state=authStateOrNull();if(state)state.authUser=user;return!0}return!1}static guard(_name){return this}static viaRemember(){return!1}static clearState(){const state=authStateOrNull();if(state){state.authUser=void 0;state.currentToken=void 0;state.clientSecret=void 0}}}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { User } from '@stacksjs/orm';
|
|
2
|
+
export declare function normalizeAuthEmail(value: string): string;
|
|
3
|
+
/** Resolve an email without making existing mixed-case accounts unloginable. */
|
|
4
|
+
export declare function findAuthUserByEmail(value: string): Promise<UserModel | undefined>;
|
|
5
|
+
declare type UserModel = NonNullable<Awaited<ReturnType<typeof User.find>>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{db,sql}from"@stacksjs/database/runtime";import{User}from"@stacksjs/orm";export function normalizeAuthEmail(value){return value.trim().toLowerCase()}export async function findAuthUserByEmail(value){const email=normalizeAuthEmail(value);if(!email)return;const exact=await User.where("email","=",email).first();if(exact)return exact;const definition=User.getDefinition(),table=definition.table||"users";if(!/^[A-Z_][A-Z0-9_]*$/i.test(table))throw TypeError("Auth user table must be a plain SQL identifier.");const softDelete=definition.traits?.useSoftDeletes?sql.raw(" AND deleted_at IS NULL"):sql.raw(""),query=sql`SELECT * FROM ${sql.raw(table)} WHERE LOWER(email) = ${email}${softDelete} LIMIT 1`,row=(await db.primary.unsafe(query.sql,query.parameters))[0];return row?await User.make(row):void 0}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from './oauth-delegated-access';
|
|
|
15
15
|
export * from './oauth-grants';
|
|
16
16
|
export * from './oauth-metadata';
|
|
17
17
|
export * from './oauth-provider';
|
|
18
|
+
export * from './oauth-pruning';
|
|
18
19
|
export * from './oauth-pkce';
|
|
19
20
|
export * from './oauth-revocation';
|
|
20
21
|
export * from './oauth-token-exchange';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
1
|
+
export*from"./authentication";export*from"./authenticator";export*from"./client";export*from"./middleware";export*from"./oauth-authorization";export*from"./oauth-authorization-endpoint";export*from"./oauth-authorization-requests";export*from"./oauth-authorization-response";export*from"./oauth-authorization-codes";export*from"./oauth-browser-session";export*from"./oauth-client-registration";export*from"./oauth-consent";export*from"./oauth-delegated-access";export*from"./oauth-grants";export*from"./oauth-metadata";export*from"./oauth-provider";export*from"./oauth-pruning";export*from"./oauth-pkce";export*from"./oauth-revocation";export*from"./oauth-token-exchange";export*from"./oauth-token-endpoint";export*from"./oauth-token-request";export*from"./oauth-token-response";export*from"./rate-limiter";export*from"./magic-link";export*from"./passkey";export*from"./password/reset";export*from"./register";export*from"./user";export*from"./tokens";export*from"./gate";export*from"./policy";export*from"./authorizable";export*from"./permissions";export*from"./rbac";export{createBqbRbacStore}from"./rbac-store-bqb";export{DEFAULT_ROLE_PACKS,seedDefaultRoles}from"./rbac-seed";export*from"./email-verification";export*from"./session-auth";export*from"./cookie-auth";export*from"./browser-session";export*from"./request-token";export*from"./page-gate";export*from"./socials";export{generateTOTP,verifyTOTP,generateTOTPSecret,totpKeyUri}from"@stacksjs/ts-auth";export*from"./two-factor";export*from"./team";export*from"./referrals";
|
|
@@ -1,4 +1,4 @@
|
|
|
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.
|
|
1
|
+
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isValidS256CodeChallenge,verifyS256CodeChallenge}from"./oauth-pkce";const invalidGrant={ok:!1,reason:"invalid_grant"},AUTHORIZATION_CODE_PATTERN=/^[A-Za-z0-9_-]{43}$/;function codeHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function validIdentifier(value){return Number.isSafeInteger(value)&&value>0}function storedValues(value){try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export async function issueAuthorizationCode(input){if(!/^[a-f0-9]{32}$/.test(input.grantId))throw TypeError("OAuth authorization code grant identifier is invalid.");if(!isValidOAuthRedirectUri(input.redirectUri))throw TypeError("OAuth authorization code redirect URI is invalid.");if(!isValidS256CodeChallenge(input.codeChallenge))throw TypeError("OAuth authorization code requires a valid S256 challenge.");if(!Number.isSafeInteger(input.lifetimeMs)||input.lifetimeMs<=0)throw TypeError("OAuth authorization code lifetime must be a positive safe integer of milliseconds.");const plainTextCode=randomBytes(32).toString("base64url"),createdAt=new Date,sql=sqlHelpers(getDatabaseDialect());await db.transaction(async(rawTrx)=>{const trx=rawTrx,grantLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",grant=(await trx.unsafe(`
|
|
2
2
|
SELECT id, client_id, subject_type, subject_id, scopes, resources, audiences, workspace_id
|
|
3
3
|
FROM oauth_grants
|
|
4
4
|
WHERE id = ${sql.param(1)} AND revoked_at IS NULL
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadOAuthAuthorizationConsentView } from './oauth-consent';
|
|
1
|
+
import { loadOAuthAuthorizationConsentView, reuseOAuthAuthorizationRequestSession } from './oauth-consent';
|
|
2
2
|
import type { OAuthAuthorizationConsentView } from './oauth-consent';
|
|
3
3
|
import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
4
4
|
/**
|
|
@@ -32,11 +32,18 @@ export declare interface HandleOAuthAuthorizationConsentRequestInput {
|
|
|
32
32
|
subjectType: string
|
|
33
33
|
subjectId: number
|
|
34
34
|
workspaceId?: string | null
|
|
35
|
+
resolveWorkspaceId?: () => Promise<string | null>
|
|
35
36
|
}
|
|
36
37
|
export declare interface OAuthAuthorizationPageIdentity {
|
|
37
38
|
label: string
|
|
38
39
|
workspaceLabel?: string | null
|
|
39
40
|
}
|
|
41
|
+
export declare interface OAuthAuthorizationPageSubject {
|
|
42
|
+
type: string
|
|
43
|
+
id: number
|
|
44
|
+
workspaceId?: string | null
|
|
45
|
+
resolveWorkspaceId?: () => Promise<string | null>
|
|
46
|
+
}
|
|
40
47
|
export declare interface OAuthAuthorizationConsentPageContext {
|
|
41
48
|
consent: OAuthAuthorizationConsentView
|
|
42
49
|
signedInIdentity: string
|
|
@@ -47,12 +54,14 @@ export declare interface OAuthAuthorizationConsentPageContext {
|
|
|
47
54
|
export declare interface OAuthAuthorizationPageDependencies {
|
|
48
55
|
begin?: typeof beginOAuthAuthorizationRequest
|
|
49
56
|
loadConsent?: typeof loadOAuthAuthorizationConsentView
|
|
57
|
+
reuseConsent?: typeof reuseOAuthAuthorizationRequestSession
|
|
50
58
|
render: (view: string, context: OAuthAuthorizationConsentPageContext) => Promise<string>
|
|
51
59
|
}
|
|
52
60
|
export declare interface HandleOAuthAuthorizationPageRequestInput {
|
|
53
61
|
provider: ResolvedOAuthProviderConfig
|
|
54
62
|
request: Request
|
|
55
63
|
identity: OAuthAuthorizationPageIdentity | null
|
|
64
|
+
subject?: OAuthAuthorizationPageSubject | null
|
|
56
65
|
csrfToken?: string
|
|
57
66
|
dependencies: OAuthAuthorizationPageDependencies
|
|
58
67
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{createOAuthAuthorizationRequestSession}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest}from"./oauth-consent";import{
|
|
1
|
+
import{OAuthAuthorizationRequestError,parseOAuthAuthorizationRequest,validateOAuthAuthorizationRequest}from"./oauth-authorization";import{bindOAuthAuthorizationRequestWorkspace,createOAuthAuthorizationRequestSession,isOAuthAuthorizationRequestId}from"./oauth-authorization-requests";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{approveOAuthAuthorizationRequestSession,denyOAuthAuthorizationRequestSession,loadOAuthAuthorizationConsentView,OAuthAuthorizationConsentRequestError,parseOAuthAuthorizationConsentRequest,reuseOAuthAuthorizationRequestSession}from"./oauth-consent";import{oauthAuthorizationBrowserSession}from"./oauth-browser-session";import{oauthAuthorizationConsentResponse,oauthAuthorizationRequestErrorResponse}from"./oauth-authorization-response";const LOCAL_ERROR_HEADERS={"Cache-Control":"no-store","Content-Type":"application/json; charset=utf-8",Pragma:"no-cache","Referrer-Policy":"no-referrer"},PAGE_SECURITY_HEADERS={"Cache-Control":"no-store","Content-Security-Policy":"frame-ancestors 'none'",Pragma:"no-cache","Referrer-Policy":"no-referrer","X-Frame-Options":"DENY"};function localAuthorizationError(status){return new Response(JSON.stringify({error:"invalid_request"}),{status,headers:LOCAL_ERROR_HEADERS})}function pageResponse(body,status,cookie,headers){return new Response(body,{status,headers:{...PAGE_SECURITY_HEADERS,"Set-Cookie":cookie,...headers}})}function resumedAuthorizationRequestId(params){const values=params.getAll("request_id");if(!values.length)return;if(values.length!==1||[...params.keys()].some((name)=>name!=="request_id"))return null;return isOAuthAuthorizationRequestId(values[0])?values[0]:null}export async function handleOAuthAuthorizationPageRequest(input){if(input.request.method!=="GET")return localAuthorizationError(405);const browser=oauthAuthorizationBrowserSession(input.request,input.provider),url=new URL(input.request.url),resumed=resumedAuthorizationRequestId(url.searchParams);if(resumed===null)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});let requestId=resumed;if(requestId===void 0)try{requestId=(await(input.dependencies.begin??beginOAuthAuthorizationRequest)({provider:input.provider,query:url.searchParams,browserSessionId:browser.id})).requestId}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}if(input.identity&&input.subject&&input.provider.consent.resolveWorkspace){if(!await bindOAuthAuthorizationRequestWorkspace(requestId,browser.id,input.subject.workspaceId??null))return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"})}if(input.identity&&input.subject&&input.provider.consent.rememberFor>0)try{const reused=await(input.dependencies.reuseConsent??reuseOAuthAuthorizationRequestSession)({provider:input.provider,requestId,browserSessionId:browser.id,subjectType:input.subject.type,subjectId:input.subject.id,workspaceId:input.subject.workspaceId,resolveWorkspaceId:input.subject.resolveWorkspaceId});if(reused){if(!reused.ok)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const protocol=oauthAuthorizationConsentResponse(reused.value),headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}}catch(error){if(!(error instanceof OAuthAuthorizationRequestError))throw error;const protocol=oauthAuthorizationRequestErrorResponse(error);if(!protocol)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const headers=new Headers(protocol.headers);headers.set("Set-Cookie",browser.cookie);return new Response(protocol.body,{status:protocol.status,headers})}const consent=await(input.dependencies.loadConsent??loadOAuthAuthorizationConsentView)({provider:input.provider,requestId,browserSessionId:browser.id});if(!consent)return pageResponse(JSON.stringify({error:"invalid_request"}),400,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});if(!input.identity){const resume=`${new URL(input.provider.endpoints.authorization).pathname}?request_id=${encodeURIComponent(requestId)}`;return pageResponse(null,302,browser.cookie,{Location:`/login?redirect=${encodeURIComponent(resume)}`})}if(!input.identity.label.trim()||!input.csrfToken)return pageResponse(JSON.stringify({error:"server_error"}),500,browser.cookie,{"Content-Type":"application/json; charset=utf-8"});const html=await input.dependencies.render(input.provider.consent.view,{consent,signedInIdentity:input.identity.label,selectedWorkspace:input.identity.workspaceLabel??null,consentAction:input.provider.endpoints.authorization,csrfToken:input.csrfToken});return pageResponse(html,200,browser.cookie,{"Content-Type":"text/html; charset=utf-8"})}export async function beginOAuthAuthorizationRequest(input){const parsed=parseOAuthAuthorizationRequest(input.query),client=await loadOAuthAuthorizationClient(parsed.clientId);if(!client)throw new OAuthAuthorizationRequestError("invalid_request","OAuth authorization request is invalid.");const request=validateOAuthAuthorizationRequest(input.provider,client,parsed);return{requestId:await createOAuthAuthorizationRequestSession(request,input.browserSessionId,input.provider.lifetimes.authorizationRequest)}}export async function handleOAuthAuthorizationConsentRequest(input){if(input.request.method!=="POST")return localAuthorizationError(405);if(input.request.headers.get("content-type")?.split(";",1)[0]?.trim().toLowerCase()!=="application/x-www-form-urlencoded")return localAuthorizationError(415);const contentLength=Number(input.request.headers.get("content-length"));if(Number.isFinite(contentLength)&&contentLength>4096)return localAuthorizationError(400);let consent;try{consent=parseOAuthAuthorizationConsentRequest(await input.request.text())}catch(error){if(error instanceof OAuthAuthorizationConsentRequestError)return localAuthorizationError(400);throw error}const browserSessionId=oauthAuthorizationBrowserSession(input.request,input.provider).id;if(consent.decision==="deny"){const denied=await denyOAuthAuthorizationRequestSession(consent.requestId,browserSessionId);return denied.ok?oauthAuthorizationConsentResponse(denied.value):localAuthorizationError(400)}let approved;try{approved=await approveOAuthAuthorizationRequestSession({provider:input.provider,requestId:consent.requestId,browserSessionId,subjectType:input.subjectType,subjectId:input.subjectId,workspaceId:input.workspaceId,resolveWorkspaceId:input.resolveWorkspaceId,authorizationCodeLifetimeMs:input.provider.lifetimes.authorizationCode})}catch(error){if(error instanceof OAuthAuthorizationRequestError)return oauthAuthorizationRequestErrorResponse(error)??localAuthorizationError(400);throw error}return approved.ok?oauthAuthorizationConsentResponse(approved.value):localAuthorizationError(400)}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ValidatedOAuthAuthorizationRequest } from './oauth-authorization';
|
|
2
2
|
export declare function isOAuthAuthorizationRequestId(value: string): boolean;
|
|
3
3
|
/** Store one validated authorization request behind an opaque, hashed handle. */
|
|
4
4
|
export declare function createOAuthAuthorizationRequestSession(request: ValidatedOAuthAuthorizationRequest, browserSessionId: string, lifetimeMs: number): Promise<string>;
|
|
5
5
|
/** Reload a request only for its original browser and current client policy. */
|
|
6
6
|
export declare function loadOAuthAuthorizationRequestSession(requestId: string, browserSessionId: string): Promise<ValidatedOAuthAuthorizationRequest | null>;
|
|
7
|
+
/** Bind the opaque request to the workspace displayed on its consent page. */
|
|
8
|
+
export declare function bindOAuthAuthorizationRequestWorkspace(requestId: string, browserSessionId: string, workspaceId: string | null): Promise<boolean>;
|
|
7
9
|
/** Claim one browser-bound request and complete consent in the same transaction. */
|
|
8
|
-
export declare function withOAuthAuthorizationRequestSession<T>(requestId: string, browserSessionId: string, complete: (
|
|
10
|
+
export declare function withOAuthAuthorizationRequestSession<T>(requestId: string, browserSessionId: string, complete: (
|
|
11
|
+
request: ValidatedOAuthAuthorizationRequest,
|
|
12
|
+
workspace: OAuthAuthorizationRequestWorkspaceBinding,
|
|
13
|
+
) => Promise<T>): Promise<OAuthAuthorizationRequestSessionResult<T>>;
|
|
14
|
+
export declare interface OAuthAuthorizationRequestWorkspaceBinding {
|
|
15
|
+
bound: boolean
|
|
16
|
+
id: string | null
|
|
17
|
+
}
|
|
9
18
|
export type OAuthAuthorizationRequestSessionResult<T> = | { ok: true, value: T }
|
|
10
19
|
| { ok: false, reason: 'invalid_request' }
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{
|
|
1
|
+
import{isValidOAuthRedirectUri,isValidOAuthState}from"./oauth-authorization";import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{loadOAuthAuthorizationClient}from"./oauth-client-registration";import{isValidS256CodeChallenge}from"./oauth-pkce";const REQUEST_ID_PATTERN=/^[A-Za-z0-9_-]{43}$/,invalidRequest={ok:!1,reason:"invalid_request"};export function isOAuthAuthorizationRequestId(value){return REQUEST_ID_PATTERN.test(value)}function requestHash(value){return createHash("sha256").update(value,"ascii").digest("hex")}function browserSessionHash(value){return createHash("sha256").update(value,"utf8").digest("hex")}function validBrowserSession(value){return value.length>=16&&value.length<=4096}function validValues(values){return values.every((value)=>value.length>0)&&new Set(values).size===values.length}function storedValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")&&validValues(parsed)?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 workspaceBindingFromStoredRow(row){const bound=row.workspace_bound===!0||row.workspace_bound===1;if(!bound&&!inactive(row.workspace_bound))return null;if(row.workspace_id!==null&&(!row.workspace_id||row.workspace_id.length>255||/[\u0000-\u001F\u007F]/.test(row.workspace_id)))return null;if(!bound&&row.workspace_id!==null)return null;return{bound,id:row.workspace_id}}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&&!isValidOAuthState(row.state))return null;const scopes=storedValues(row.scopes),resources=storedValues(row.resources),audiences=storedValues(row.audiences);if(!scopes?.length||!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&&!isValidOAuthState(request.state))throw TypeError("OAuth authorization request state must contain 1 to 4096 visible ASCII characters.");if(!request.scopes.length||!validValues(request.scopes))throw TypeError("OAuth authorization request scopes must contain unique non-empty values.");if(!validValues(request.resources)||!validValues(request.audiences)||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request resources and audiences must contain unique paired values.");if(!isValidOAuthRedirectUri(request.redirectUri)||!isValidS256CodeChallenge(request.codeChallenge)||request.codeChallengeMethod!=="S256"||request.resources.length!==request.audiences.length)throw TypeError("OAuth authorization request is invalid.");const registered=await loadOAuthAuthorizationClient(request.clientId);if(!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))throw Error("OAuth client registration no longer authorizes this request.");const id=randomBytes(32).toString("base64url"),createdAt=new Date,expiresAt=new Date(createdAt.getTime()+lifetimeMs);if(!Number.isFinite(expiresAt.getTime()))throw TypeError("OAuth authorization request lifetime is outside the supported date range.");const sql=sqlHelpers(getDatabaseDialect());if(sql.isMysql){createdAt.setUTCMilliseconds(0);expiresAt.setUTCMilliseconds(0)}if(expiresAt.getTime()<=createdAt.getTime())throw TypeError("OAuth authorization request lifetime is below database timestamp precision.");const values=sql.params(requestHash(id),browserSessionHash(browserSessionId),clientId,request.clientType,request.redirectUri,JSON.stringify(request.scopes),JSON.stringify(request.resources),JSON.stringify(request.audiences),request.state,request.codeChallenge,request.codeChallengeMethod,sqlDateTime(expiresAt),sqlDateTime(createdAt));await db.unsafe(`
|
|
2
2
|
INSERT INTO oauth_authorization_requests (
|
|
3
3
|
request_hash, browser_session_hash, client_id, client_type, redirect_uri,
|
|
4
4
|
scopes, resources, audiences, state, code_challenge, code_challenge_method,
|
|
5
5
|
expires_at, created_at
|
|
6
6
|
) VALUES (${values.sql})
|
|
7
7
|
`,values.values);markContextWrote();return id}export async function loadOAuthAuthorizationRequestSession(requestId,browserSessionId){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return null;const sql=sqlHelpers(getDatabaseDialect()),row=(await db.primary.unsafe(`
|
|
8
|
-
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences,
|
|
8
|
+
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences, workspace_id, workspace_bound,
|
|
9
9
|
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
10
10
|
FROM oauth_authorization_requests
|
|
11
11
|
WHERE request_hash = ${sql.param(1)}
|
|
@@ -13,14 +13,28 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
13
13
|
AND consumed_at IS NULL
|
|
14
14
|
AND expires_at > ${sql.param(3)}
|
|
15
15
|
LIMIT 1
|
|
16
|
-
`,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!registered||registered.revoked||registered.type!==request.clientType||!registered.redirectUris.includes(request.redirectUri)||!registered.grantTypes.includes("authorization_code")||!includesAll(registered.scopes,request.scopes)||!includesAll(registered.resources,request.resources))return null;return request}export async function
|
|
17
|
-
SELECT
|
|
16
|
+
`,[requestHash(requestId),browserSessionHash(browserSessionId),sqlDateTime(new Date)]))[0];if(!row)return null;const request=requestFromStoredRow(row,Date.now()),workspace=workspaceBindingFromStoredRow(row),registered=await loadOAuthAuthorizationClient(String(row.client_id));if(!request||!workspace||!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 bindOAuthAuthorizationRequestWorkspace(requestId,browserSessionId,workspaceId){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId)||workspaceId!==null&&(!workspaceId||workspaceId.length>255||/[\u0000-\u001F\u007F]/.test(workspaceId)))return!1;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=sqlDateTime(new Date),row=(await trx.unsafe(`
|
|
17
|
+
SELECT workspace_id, workspace_bound, expires_at, consumed_at
|
|
18
|
+
FROM oauth_authorization_requests
|
|
19
|
+
WHERE request_hash = ${sql.param(1)}
|
|
20
|
+
AND browser_session_hash = ${sql.param(2)}
|
|
21
|
+
LIMIT 1${requestLock}
|
|
22
|
+
`,[hash,sessionHash]))[0],binding=row?workspaceBindingFromStoredRow(row):null;if(!row||!binding||row.consumed_at!=null||(parseSqlDateTime(row.expires_at)?.getTime()??0)<=Date.now())return{matches:!1,wrote:!1};if(binding.bound)return{matches:binding.id===workspaceId,wrote:!1};const updated=await trx.unsafe(`
|
|
23
|
+
UPDATE oauth_authorization_requests
|
|
24
|
+
SET workspace_id = ${sql.param(1)}, workspace_bound = ${sql.boolTrue}
|
|
25
|
+
WHERE request_hash = ${sql.param(2)}
|
|
26
|
+
AND browser_session_hash = ${sql.param(3)}
|
|
27
|
+
AND workspace_bound = ${sql.boolFalse}
|
|
28
|
+
AND consumed_at IS NULL
|
|
29
|
+
AND expires_at > ${sql.param(4)}
|
|
30
|
+
`,[workspaceId,hash,sessionHash,now]),wrote=mutationCount(updated)===1;return{matches:wrote,wrote}});if(result.wrote)markContextWrote();return result.matches}export async function withOAuthAuthorizationRequestSession(requestId,browserSessionId,complete){if(!isOAuthAuthorizationRequestId(requestId)||!validBrowserSession(browserSessionId))return invalidRequest;const sql=sqlHelpers(getDatabaseDialect()),hash=requestHash(requestId),sessionHash=browserSessionHash(browserSessionId),requestLock=sql.isSqlite?"":" FOR UPDATE",clientLock=sql.isPostgres?" FOR SHARE":sql.isMysql?" LOCK IN SHARE MODE":"",result=await db.transaction(async(rawTrx)=>{const trx=rawTrx,now=new Date,nowSql=sqlDateTime(now),rows=await trx.unsafe(`
|
|
31
|
+
SELECT client_id, client_type, redirect_uri, scopes, resources, audiences, workspace_id, workspace_bound,
|
|
18
32
|
state, code_challenge, code_challenge_method, expires_at, consumed_at
|
|
19
33
|
FROM oauth_authorization_requests
|
|
20
34
|
WHERE request_hash = ${sql.param(1)}
|
|
21
35
|
AND browser_session_hash = ${sql.param(2)}
|
|
22
36
|
LIMIT 1${requestLock}
|
|
23
|
-
`,[hash,sessionHash]),request=rows[0]?requestFromStoredRow(rows[0],now.getTime()):null;if(!request)return invalidRequest;const clients=await trx.unsafe(`
|
|
37
|
+
`,[hash,sessionHash]),request=rows[0]?requestFromStoredRow(rows[0],now.getTime()):null,workspace=rows[0]?workspaceBindingFromStoredRow(rows[0]):null;if(!request||!workspace)return invalidRequest;const clients=await trx.unsafe(`
|
|
24
38
|
SELECT id, secret, redirect, client_type, redirect_uris, grant_types,
|
|
25
39
|
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
26
40
|
personal_access_client, password_client, revoked
|
|
@@ -34,4 +48,4 @@ import{createHash,randomBytes}from"node:crypto";import{db,getDatabaseDialect,mar
|
|
|
34
48
|
AND browser_session_hash = ${sql.param(3)}
|
|
35
49
|
AND consumed_at IS NULL
|
|
36
50
|
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}
|
|
51
|
+
`,[nowSql,hash,sessionHash,nowSql]);if(mutationCount(claimed)!==1)return invalidRequest;return{ok:!0,value:await complete(request,workspace)}});if(result.ok)markContextWrote();return result}
|
|
@@ -3,6 +3,8 @@ import type { ResolvedOAuthProviderConfig } from './oauth-provider';
|
|
|
3
3
|
export declare function parseOAuthAuthorizationRequest(query: string | URLSearchParams): OAuthAuthorizationRequestInput;
|
|
4
4
|
/** Whether a registered callback is safe for exact redirect matching. */
|
|
5
5
|
export declare function isValidOAuthRedirectUri(value: string): boolean;
|
|
6
|
+
/** Whether an optional OAuth state value is safe to persist and reflect. */
|
|
7
|
+
export declare function isValidOAuthState(value: string): boolean;
|
|
6
8
|
/** Recheck provider-owned policy immediately before consent creates a grant. */
|
|
7
9
|
export declare function assertOAuthAuthorizationProviderPolicy(provider: ResolvedOAuthProviderConfig, request: ValidatedOAuthAuthorizationRequest): void;
|
|
8
10
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}function scalarParameter(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth authorization parameter must not be repeated: ${name}`);if(required&&(!values.length||values[0]===""))reject("invalid_request",`OAuth authorization parameter is required: ${name}`);return values[0]}export function parseOAuthAuthorizationRequest(query){const encoded=typeof query==="string"?query.replace(/^\?/,""):query.toString();if(encoded.length>8192)reject("invalid_request","OAuth authorization request is too large.");const params=typeof query==="string"?new URLSearchParams(encoded):query,responseType=scalarParameter(params,"response_type",!0),clientId=scalarParameter(params,"client_id",!0),redirectUri=scalarParameter(params,"redirect_uri",!0),scope=scalarParameter(params,"scope"),state=scalarParameter(params,"state"),codeChallenge=scalarParameter(params,"code_challenge",!0),codeChallengeMethod=scalarParameter(params,"code_challenge_method",!0),resources=params.getAll("resource");return{responseType,clientId,redirectUri,codeChallenge,codeChallengeMethod,...scope===void 0?{}:{scope},...resources.length?{resource:resources}:{},...state===void 0?{}:{state}}}export function isValidOAuthRedirectUri(value){if(value.includes("*"))return!1;let url;try{url=new URL(value)}catch{return!1}if(url.username||url.password||url.hash)return!1;const loopback=url.hostname==="localhost"||url.hostname==="127.0.0.1"||url.hostname==="[::1]";return url.protocol==="https:"||url.protocol==="http:"&&loopback}function requestedScopes(value,redirectUri,state){if(value==null||value==="")
|
|
1
|
+
import{isValidS256CodeChallenge}from"./oauth-pkce";export class OAuthAuthorizationRequestError extends Error{code;redirectUri;state;constructor(code,message,redirectUri=null,state=null){super(message);this.code=code;this.redirectUri=redirectUri;this.state=state;this.name="OAuthAuthorizationRequestError"}}function reject(code,message,redirectUri=null,state=null){throw new OAuthAuthorizationRequestError(code,message,redirectUri,state)}function scalarParameter(params,name,required=!1){const values=params.getAll(name);if(values.length>1)reject("invalid_request",`OAuth authorization parameter must not be repeated: ${name}`);if(required&&(!values.length||values[0]===""))reject("invalid_request",`OAuth authorization parameter is required: ${name}`);return values[0]}export function parseOAuthAuthorizationRequest(query){const encoded=typeof query==="string"?query.replace(/^\?/,""):query.toString();if(encoded.length>8192)reject("invalid_request","OAuth authorization request is too large.");const params=typeof query==="string"?new URLSearchParams(encoded):query,responseType=scalarParameter(params,"response_type",!0),clientId=scalarParameter(params,"client_id",!0),redirectUri=scalarParameter(params,"redirect_uri",!0),scope=scalarParameter(params,"scope"),state=scalarParameter(params,"state"),codeChallenge=scalarParameter(params,"code_challenge",!0),codeChallengeMethod=scalarParameter(params,"code_challenge_method",!0),resources=params.getAll("resource");return{responseType,clientId,redirectUri,codeChallenge,codeChallengeMethod,...scope===void 0?{}:{scope},...resources.length?{resource:resources}:{},...state===void 0?{}:{state}}}export function isValidOAuthRedirectUri(value){if(value.length>2048||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}export function isValidOAuthState(value){return/^[\x20-\x7E]{1,4096}$/.test(value)}function requestedScopes(value,redirectUri,state){if(value==null||value==="")reject("invalid_scope","OAuth authorization requests require at least one scope.",redirectUri,state);const scopes=value.split(" ");if(scopes.some((scope)=>!scope||!/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))reject("invalid_scope","OAuth scopes must be space-separated visible ASCII tokens.",redirectUri,state);return[...new Set(scopes)]}function requestedAudiences(value,redirectUri,state){if(value==null)return[];const resources=typeof value==="string"?[value]:[...value];if(resources.some((resource)=>!resource))reject("invalid_target","OAuth resources must not be empty.",redirectUri,state);return[...new Set(resources)]}export function assertOAuthAuthorizationProviderPolicy(provider,request){if(!provider.clientTypes.includes(request.clientType))reject("unauthorized_client","OAuth client type is no longer enabled by this provider.",request.redirectUri,request.state);for(const scope of request.scopes)if(!provider.scopes[scope])reject("invalid_scope",`OAuth scope is no longer registered by this provider: ${scope}`,request.redirectUri,request.state);if(request.resources.length!==request.audiences.length)reject("invalid_target","OAuth resource bindings are invalid.",request.redirectUri,request.state);for(const[index,resource]of request.resources.entries())if(provider.resources[resource]?.audience!==request.audiences[index])reject("invalid_target",`OAuth resource is no longer registered by this provider: ${resource}`,request.redirectUri,request.state);for(const scope of request.scopes){const allowedResources=provider.scopes[scope]?.resources;if(!allowedResources?.length)continue;if(!request.resources.length||request.resources.some((resource)=>!allowedResources.includes(resource)))reject("invalid_target",`OAuth scope is not valid for the requested resource: ${scope}`,request.redirectUri,request.state)}}export function validateOAuthAuthorizationRequest(provider,client,input){if(String(client.id)!==input.clientId)reject("invalid_request","OAuth client identifier does not match the loaded client.");if(client.revoked)reject("unauthorized_client","OAuth client is disabled.");if(!provider.clientTypes.includes(client.type))reject("unauthorized_client","OAuth client type is not enabled by this provider.");if(!client.redirectUris.includes(input.redirectUri)||!isValidOAuthRedirectUri(input.redirectUri))reject("invalid_request","OAuth redirect URI is not an exact secure registration match.");const state=typeof input.state==="string"?input.state:null,redirectUri=input.redirectUri;if(state!==null&&!isValidOAuthState(state))reject("invalid_request","OAuth authorization request state must contain 1 to 4096 visible ASCII characters.",redirectUri);if(input.responseType!=="code")reject("unsupported_response_type","Only response_type=code is supported.",redirectUri,state);if(!client.grantTypes.includes("authorization_code"))reject("unauthorized_client","OAuth client may not use the authorization code grant.",redirectUri,state);if(input.codeChallengeMethod!=="S256")reject("invalid_request","OAuth authorization requests require code_challenge_method=S256.",redirectUri,state);if(!isValidS256CodeChallenge(input.codeChallenge))reject("invalid_request","OAuth authorization request has an invalid S256 code challenge.",redirectUri,state);const scopes=requestedScopes(input.scope,redirectUri,state);for(const scope of scopes)if(!client.scopes.includes(scope))reject("invalid_scope",`OAuth scope is not registered for this client: ${scope}`,redirectUri,state);const audiences=requestedAudiences(input.resource,redirectUri,state),resources=[];for(const audience of audiences){const matchingResources=Object.entries(provider.resources).filter(([,resource])=>resource.audience===audience).map(([key])=>key);if(matchingResources.length!==1||!client.resources.includes(matchingResources[0]))reject("invalid_target",`OAuth resource is not registered for this client: ${audience}`,redirectUri,state);resources.push(matchingResources[0])}const validated={responseType:"code",clientId:input.clientId,clientType:client.type,redirectUri,scopes,resources,audiences,state,codeChallenge:input.codeChallenge,codeChallengeMethod:"S256"};assertOAuthAuthorizationProviderPolicy(provider,validated);return validated}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OAuthAuthorizationClientRegistration } from './oauth-authorization';
|
|
2
|
+
export declare function storedOAuthValues(value: string | null): string[] | null;
|
|
3
|
+
export declare function isInactiveOAuthFlag(value: boolean | number): boolean;
|
|
4
|
+
export declare function oauthAuthorizationClientFromStored(row: StoredOAuthAuthorizationClient | undefined, id: number): OAuthAuthorizationClientRegistration | null;
|
|
5
|
+
export declare interface StoredOAuthAuthorizationClient {
|
|
6
|
+
id: number | string
|
|
7
|
+
secret: string | null
|
|
8
|
+
redirect: string
|
|
9
|
+
client_type: string | null
|
|
10
|
+
redirect_uris: string | null
|
|
11
|
+
grant_types: string | null
|
|
12
|
+
token_endpoint_auth_method: string | null
|
|
13
|
+
allowed_scopes: string | null
|
|
14
|
+
allowed_resources: string | null
|
|
15
|
+
personal_access_client: boolean | number
|
|
16
|
+
password_client: boolean | number
|
|
17
|
+
revoked: boolean | number
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isValidOAuthRedirectUri}from"./oauth-authorization";export function storedOAuthValues(value){if(value==null)return null;try{const parsed=JSON.parse(value);return Array.isArray(parsed)&&parsed.every((item)=>typeof item==="string")?parsed:null}catch{return null}}export function isInactiveOAuthFlag(value){return value===!1||value===0}function storedOAuthFlag(value){if(isInactiveOAuthFlag(value))return!1;if(value===!0||value===1)return!0;return null}export function oauthAuthorizationClientFromStored(row,id){const redirectUris=storedOAuthValues(row?.redirect_uris??null),grantTypes=storedOAuthValues(row?.grant_types??null),scopes=storedOAuthValues(row?.allowed_scopes??null),resources=storedOAuthValues(row?.allowed_resources??null),revoked=row?storedOAuthFlag(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]||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||revoked==null)return null;return{id,type,revoked,redirectUris,grantTypes,scopes,resources}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";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
|
|
1
|
+
import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWrote,mutationCount,parseSqlDateTime,sqlDateTime,sqlHelpers}from"@stacksjs/database/runtime";import{makeHash,verifyHash}from"@stacksjs/security";import{isValidOAuthRedirectUri}from"./oauth-authorization";import{isInactiveOAuthFlag,oauthAuthorizationClientFromStored,storedOAuthValues}from"./oauth-client-policy";export class OAuthClientRegistrationError extends Error{constructor(message){super(message);this.name="OAuthClientRegistrationError"}}function reject(message){throw new OAuthClientRegistrationError(message)}function explicitUnique(name,values,options={}){if(options.required&&values.length===0)reject(`OAuth client must register at least one ${name}.`);if(values.some((value)=>!value))reject(`OAuth client ${name} entries must not be empty.`);if(new Set(values).size!==values.length)reject(`OAuth client ${name} contains a duplicate entry.`);return[...values]}export function validateOAuthClientRegistration(provider,input){const name=input.name.trim();if(!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))reject("OAuth client name must be 1 to 100 characters without control characters.");if(!provider.clientTypes.includes(input.type))reject(`OAuth client type is not enabled by this provider: ${input.type}`);if(input.type==="public"&&input.tokenEndpointAuthMethod!=="none")reject("Public OAuth clients must use token endpoint authentication method none.");if(input.type==="confidential"&&input.tokenEndpointAuthMethod!=="client_secret_basic")reject("Confidential OAuth clients must use token endpoint authentication method client_secret_basic.");const 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 sameValues(left,right){return left.length===right.length&&left.every((value,index)=>value===right[index])}async function revokeOAuthClientAuthorizationState(trx,sql,clientId,now){const revoked=sql.isPostgres?!0:1;await trx.unsafe(`
|
|
2
2
|
UPDATE oauth_grants
|
|
3
3
|
SET revoked_at = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
4
4
|
WHERE client_id = ${sql.param(3)} AND revoked_at IS NULL
|
|
@@ -20,26 +20,26 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
20
20
|
UPDATE oauth_access_tokens
|
|
21
21
|
SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
22
22
|
WHERE oauth_client_id = ${sql.param(3)}
|
|
23
|
-
`,[revoked,now,clientId])}
|
|
23
|
+
`,[revoked,now,clientId])}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
24
|
SELECT id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
25
25
|
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
26
26
|
personal_access_client, password_client, revoked
|
|
27
27
|
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1
|
|
28
|
-
`,[id]))[0],client=
|
|
28
|
+
`,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);return row&&client?{client,row}:null}export async function loadOAuthAuthorizationClient(clientId){return(await storedAuthorizationClient(clientId))?.client??null}export async function loadOAuthAuthorizationClientDetails(clientId){const stored=await storedAuthorizationClient(clientId),name=typeof stored?.row.name==="string"?stored.row.name.trim():"";if(!stored||!name||name.length>100||/[\u0000-\u001F\u007F]/.test(name))return null;return{...stored.client,name}}export async function listOAuthClients(ownerId){if(!Number.isSafeInteger(ownerId)||ownerId<=0)return[];const sql=sqlHelpers(getDatabaseDialect());return(await db.primary.unsafe(`
|
|
29
29
|
SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
30
30
|
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
31
31
|
password_client, revoked, created_at
|
|
32
32
|
FROM oauth_clients
|
|
33
33
|
WHERE user_id = ${sql.param(1)}
|
|
34
34
|
ORDER BY created_at DESC, id DESC
|
|
35
|
-
`,[ownerId])).flatMap((row)=>{const id=Number(row.id),storedOwnerId=Number(row.user_id),client=
|
|
35
|
+
`,[ownerId])).flatMap((row)=>{const id=Number(row.id),storedOwnerId=Number(row.user_id),client=oauthAuthorizationClientFromStored(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
36
|
SELECT id, user_id, name, secret, redirect, client_type, redirect_uris, grant_types,
|
|
37
37
|
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
38
38
|
password_client, revoked, created_at
|
|
39
39
|
FROM oauth_clients
|
|
40
40
|
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
41
41
|
LIMIT 1${lock}
|
|
42
|
-
`,[clientId,ownerId]))[0],current=
|
|
42
|
+
`,[clientId,ownerId]))[0],current=oauthAuthorizationClientFromStored(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
43
|
UPDATE oauth_clients
|
|
44
44
|
SET name = ${sql.param(1)}, redirect = ${sql.param(2)}, redirect_uris = ${sql.param(3)},
|
|
45
45
|
grant_types = ${sql.param(4)}, allowed_scopes = ${sql.param(5)}, allowed_resources = ${sql.param(6)},
|
|
@@ -50,28 +50,28 @@ import{randomBytes}from"node:crypto";import{db,getDatabaseDialect,markContextWro
|
|
|
50
50
|
token_endpoint_auth_method, allowed_scopes, allowed_resources,
|
|
51
51
|
personal_access_client, password_client, revoked
|
|
52
52
|
FROM oauth_clients WHERE id = ${sql.param(1)} LIMIT 1${lock}
|
|
53
|
-
`,[id]))[0],client=
|
|
53
|
+
`,[id]))[0],client=oauthAuthorizationClientFromStored(row,id);if(!row||!client||client.revoked)return null;if(client.type==="public")return clientSecret===void 0?complete(client):null;if(!clientSecret||!row.secret)return null;let verified=!1;try{verified=await verifyHash(clientSecret,row.secret)}catch{return null}return verified?complete(client):null})}export async function registerOAuthClient(provider,ownerId,input){if(!Number.isSafeInteger(ownerId)||ownerId<=0)throw TypeError("OAuth client owner identifier must be a positive safe integer.");const client=validateOAuthClientRegistration(provider,input),plainTextSecret=client.requiresSecret?randomBytes(40).toString("hex"):void 0,storedSecret=plainTextSecret?await makeHash(plainTextSecret,{algorithm:"bcrypt"}):null,sql=sqlHelpers(getDatabaseDialect()),createdAt=new Date;if(sql.isMysql)createdAt.setUTCMilliseconds(0);const disabled=sql.isPostgres?!1:0,stored=await db.transaction(async(rawTrx)=>{const trx=rawTrx,values=sql.params(ownerId,client.name,storedSecret,"local",client.redirectUris[0],client.type,JSON.stringify(client.redirectUris),JSON.stringify(client.grantTypes),client.tokenEndpointAuthMethod,JSON.stringify(client.scopes),JSON.stringify(client.resources),disabled,disabled,disabled,sqlDateTime(createdAt)),returning=sql.isMysql?"":" RETURNING id",inserted=await trx.unsafe(`
|
|
54
54
|
INSERT INTO oauth_clients (
|
|
55
55
|
user_id, name, secret, provider, redirect, client_type, redirect_uris, grant_types,
|
|
56
56
|
token_endpoint_auth_method, allowed_scopes, allowed_resources, personal_access_client,
|
|
57
57
|
password_client, revoked, created_at
|
|
58
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(
|
|
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(storedOAuthValues(row.redirect_uris))!==JSON.stringify(client.redirectUris)||JSON.stringify(storedOAuthValues(row.grant_types))!==JSON.stringify(client.grantTypes)||row.token_endpoint_auth_method!==client.tokenEndpointAuthMethod||JSON.stringify(storedOAuthValues(row.allowed_scopes))!==JSON.stringify(client.scopes)||JSON.stringify(storedOAuthValues(row.allowed_resources))!==JSON.stringify(client.resources)||!isInactiveOAuthFlag(row.personal_access_client)||!isInactiveOAuthFlag(row.password_client)||!isInactiveOAuthFlag(row.revoked))throw Error("Failed to persist the registered OAuth client policy.");return{id,createdAt:parseSqlDateTime(row.created_at)??createdAt}});markContextWrote();return{client:{id:stored.id,ownerId,name:client.name,type:client.type,tokenEndpointAuthMethod:client.tokenEndpointAuthMethod,redirectUris:client.redirectUris,grantTypes:client.grantTypes,scopes:client.scopes,resources:client.resources,revoked:!1,createdAt:stored.createdAt},...plainTextSecret===void 0?{}:{plainTextSecret}}}export async function rotateOAuthClientSecret(ownerId,clientId){if(!Number.isSafeInteger(ownerId)||ownerId<=0||!Number.isSafeInteger(clientId)||clientId<=0)return null;const plainTextSecret=randomBytes(40).toString("hex"),storedSecret=await makeHash(plainTextSecret,{algorithm:"bcrypt"}),sql=sqlHelpers(getDatabaseDialect()),lock=sql.isSqlite?"":" FOR UPDATE";if(!await db.transaction(async(rawTrx)=>{const trx=rawTrx,client=(await trx.unsafe(`
|
|
60
60
|
SELECT client_type, token_endpoint_auth_method, revoked
|
|
61
61
|
FROM oauth_clients
|
|
62
62
|
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
63
63
|
LIMIT 1${lock}
|
|
64
|
-
`,[clientId,ownerId]))[0];if(!client||client.client_type!=="confidential"||client.token_endpoint_auth_method!=="client_secret_basic"||!
|
|
64
|
+
`,[clientId,ownerId]))[0];if(!client||client.client_type!=="confidential"||client.token_endpoint_auth_method!=="client_secret_basic"||!isInactiveOAuthFlag(client.revoked))return!1;const active=sql.isPostgres?!1:0,now=sqlDateTime(new Date),changed=await trx.unsafe(`
|
|
65
65
|
UPDATE oauth_clients
|
|
66
66
|
SET secret = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
67
67
|
WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)}
|
|
68
68
|
AND client_type = ${sql.param(5)} AND token_endpoint_auth_method = ${sql.param(6)}
|
|
69
69
|
AND revoked = ${sql.param(7)}
|
|
70
|
-
`,[storedSecret,
|
|
70
|
+
`,[storedSecret,now,clientId,ownerId,"confidential","client_secret_basic",active]);if(mutationCount(changed)!==1)return!1;await revokeOAuthClientAuthorizationState(trx,sql,clientId,now);return!0}))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
71
|
SELECT id, revoked FROM oauth_clients
|
|
72
72
|
WHERE id = ${sql.param(1)} AND user_id = ${sql.param(2)}
|
|
73
73
|
LIMIT 1${lock}
|
|
74
|
-
`,[clientId,ownerId]);if(!rows[0]||!
|
|
74
|
+
`,[clientId,ownerId]);if(!rows[0]||!isInactiveOAuthFlag(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
75
|
UPDATE oauth_clients
|
|
76
76
|
SET revoked = ${sql.param(1)}, updated_at = ${sql.param(2)}
|
|
77
77
|
WHERE id = ${sql.param(3)} AND user_id = ${sql.param(4)} AND revoked = ${sql.param(5)}
|