@stacksjs/auth 0.75.182 → 0.75.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tokens.d.ts CHANGED
@@ -1,4 +1,39 @@
1
1
  import type { AccessToken, CreateClientOptions, CreateClientResult, OAuthClient, PersonalAccessTokenResult, RefreshTokenResult, TokenScopes } from '@stacksjs/types';
2
+ /**
3
+ * The row that came back is the row that was written.
4
+ *
5
+ * `expires_at` is checked by the database, through `expires_at_matches`, and
6
+ * not by comparing a parsed Date against the instant asked for. A zoneless
7
+ * column carries no offset, so the value a driver hands back is its own
8
+ * reading of it: the same naive literal came back as a Date eight hours early
9
+ * in one API process while a standalone script against the same column and the
10
+ * same `db` export round-tripped exactly, which rejected correctly stored
11
+ * tokens and 500'd every login and register on that host
12
+ * (stacksjs/stacks#2902).
13
+ *
14
+ * Asking the database compares the stored value against the literal that was
15
+ * written, in the type system that stored it, which is the question this check
16
+ * was always asking. Nothing in the path then depends on how a driver chose to
17
+ * localize a column that names no zone.
18
+ *
19
+ * One consequence worth stating: on a column Postgres holds as `timestamptz`
20
+ * this now passes. The literal is read through the session zone on the way in
21
+ * and again on the way out, so it equals itself while the instant stored is
22
+ * not the instant the app asked for. That is a schema fault rather than a
23
+ * failed insert, and it has its own louder report in
24
+ * `ensureUtcDatetimeColumns`, which names the column and prints the statement
25
+ * that converts it. This check used to fail on it by accident, while also
26
+ * failing on correctly stored rows, which is what took logins down.
27
+ *
28
+ * Exported for tests.
29
+ */
30
+ export declare function verifyAccessTokenInsert(row: AccessTokenRow, expected: {
31
+ ownerId: number
32
+ ownerType: string
33
+ clientId: number
34
+ scopes: TokenScopes
35
+ expiresAt: Date
36
+ }): void;
2
37
  export declare function getTokenOwnerCredentialState(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<TokenOwnerCredentialState>;
3
38
  export declare function getPasswordChangedAt(ownerId: unknown, q?: { unsafe: (sql: string, params?: any[]) => any }, ownerType?: string): Promise<Date | null>;
4
39
  /**
@@ -302,3 +337,41 @@ export declare interface TokenOwnerCredentialState {
302
337
  exists: boolean
303
338
  passwordChangedAt: Date | null
304
339
  }
340
+ /**
341
+ * The token tables, as these queries select them.
342
+ *
343
+ * `db.unsafe` answers `Record<string, unknown>`, so every field read off a
344
+ * result was `unknown` and eleven call sites cast the whole array to `any[]`
345
+ * to get at it - which meant a column renamed in a migration, or misspelt in
346
+ * one of the SQL strings below, read as `undefined` and flowed silently into a
347
+ * token record.
348
+ *
349
+ * These tables are not in the generated `database/types.d.ts`, so the shapes
350
+ * are written here, beside the queries that select them. Fields are typed as
351
+ * the drivers return them: sqlite gives 0/1 for booleans and strings for
352
+ * dates, which is why the code coerces at each use.
353
+ */
354
+ declare interface AccessTokenRow {
355
+ id: number
356
+ tokenable_type: string
357
+ tokenable_id: number
358
+ user_id: number
359
+ name: string
360
+ scopes: string | null
361
+ revoked: number | boolean | null
362
+ expires_at: string | null
363
+ expires_at_matches?: boolean | number | null
364
+ created_at: string
365
+ updated_at: string | null
366
+ ip_address: string | null
367
+ user_agent: string | null
368
+ oauth_client_id: number
369
+ oauth_grant_id: string | null
370
+ resources: string | null
371
+ audiences: string | null
372
+ workspace_id: string | null
373
+ grant_scopes: string | null
374
+ password_changed_at: string | null
375
+ access_token_id: number | null
376
+ provider: string | null
377
+ }
package/dist/tokens.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/config";import{db,getDatabaseDialect,markContextWrote,mutationCount}from"@stacksjs/database/runtime";import{HttpError}from"@stacksjs/error-handling";import{getCurrentRequest}from"@stacksjs/router";import{makeHash}from"@stacksjs/security";import{revokeTokenPair,revokeTokenPairs}from"./token-revocation";import{requestToken}from"./request-token";import{tokenDate,tokenTimestamps}from"./token-dates";import{parseSqlDateTime,sqlDateTime,sqlDateTimeLiteral,sqlHelpers}from"@stacksjs/database/runtime";function tokenSql(){return sqlHelpers(getDatabaseDialect())}function appNow(){return sqlDateTimeLiteral()}function validTokenExpiry(value){if(value==null)return!0;const expiry=parseSqlDateTime(value);return expiry!=null&&expiry.getTime()>Date.now()}async function verifyRefreshTokenInsert(trx,hash,accessTokenId,expiresAt){const{param}=tokenSql(),rows=await trx.unsafe(`
2
2
  SELECT access_token_id, revoked, expires_at FROM oauth_refresh_tokens WHERE token = ${param(1)}
3
- `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||parseSqlDateTime(row.expires_at)?.getTime()!==expected.expiresAt.getTime()||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date(Date.now()+days*24*60*60*1000);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";function databaseBoolean(value){return value===!0||value===1||value===1n||value==="1"||value==="t"}function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getTokenOwnerCredentialState(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return{exists:!1,passwordChangedAt:null};const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`,readOwner=async(includePasswordStamp)=>{const row=(await q.unsafe(`
3
+ `,[hash]),row=rows[0],storedExpiry=parseSqlDateTime(row?.expires_at)?.getTime(),expectedExpiry=expiresAt.getTime();if(rows.length!==1||!row||String(row.access_token_id)!==String(accessTokenId)||row.revoked!==!1&&row.revoked!==0||storedExpiry!==expectedExpiry)throw new HttpError(500,"Failed to persist the requested refresh token")}function storedAsWritten(matches){return matches===!0||matches===1}export function verifyAccessTokenInsert(row,expected){if(String(row.tokenable_id)!==String(expected.ownerId)||String(row.user_id)!==String(expected.ownerId)||row.tokenable_type!==expected.ownerType||String(row.oauth_client_id)!==String(expected.clientId)||row.revoked!==!1&&row.revoked!==0||!storedAsWritten(row.expires_at_matches)||JSON.stringify(parseScopes(row.scopes))!==JSON.stringify(expected.scopes))throw new HttpError(500,"Failed to persist the requested access token")}function refreshDeadline(days){const{isMysql}=tokenSql(),deadline=new Date(Date.now()+days*24*60*60*1000);if(isMysql)deadline.setUTCMilliseconds(0);return deadline}function hashToken(token){return createHash("sha256").update(token).digest("hex")}function bearerLookupHash(bearer){const colonIdx=bearer.indexOf(":"),lookup=colonIdx===-1?bearer:bearer.substring(0,colonIdx);return hashToken(lookup)}function generateSecureToken(bytes=40){return randomBytes(bytes).toString("hex")}export const DEFAULT_TOKENABLE_TYPE="users";function databaseBoolean(value){return value===!0||value===1||value===1n||value==="1"||value==="t"}function delegatedContext(row){return{grantId:row.oauth_grant_id??null,resources:row.oauth_grant_id?parseScopes(row.resources):[],audiences:row.oauth_grant_id?parseScopes(row.audiences):[],workspaceId:row.oauth_grant_id?row.workspace_id??null:null}}export async function getTokenOwnerCredentialState(ownerId,q=db,ownerType=DEFAULT_TOKENABLE_TYPE){const{isMysql,isPostgres,param}=tokenSql();if(ownerId===null||ownerId===void 0)return{exists:!1,passwordChangedAt:null};const quote=isMysql?"`":'"',table=`${quote}${ownerType.replaceAll(quote,quote+quote)}${quote}`,readOwner=async(includePasswordStamp)=>{const row=(await q.unsafe(`
4
4
  SELECT ${includePasswordStamp?"id, password_changed_at":"id"} FROM ${table} WHERE id = ${param(1)} LIMIT 1
5
5
  `,[ownerId]))[0];if(!row)return{exists:!1,passwordChangedAt:null};const value=row.password_changed_at;return{exists:!0,passwordChangedAt:value===null||value===void 0?null:parseSqlDateTime(value)}};try{if(isMysql){const schema=(await q.unsafe(`
6
6
  SELECT
@@ -65,9 +65,11 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
65
65
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ${appNow()}, ${appNow()})
66
66
  `,[tokenableType,userId,userId,client.id,hashedToken,name,JSON.stringify(scopes),sqlDateTime(expiresAt),agent,address]);const row=(await trx.unsafe(`
67
67
  SELECT id, user_id, tokenable_type, tokenable_id, oauth_client_id,
68
- name, scopes, revoked, expires_at, created_at, updated_at
69
- FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
70
- `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");verifyAccessTokenInsert(row,{ownerId:userId,ownerType:tokenableType,clientId:client.id,scopes,expiresAt});const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,...tokenTimestamps(row)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
68
+ name, scopes, revoked, expires_at,
69
+ (expires_at = ${param(1)}) AS expires_at_matches,
70
+ created_at, updated_at
71
+ FROM oauth_access_tokens WHERE token = ${param(2)} LIMIT 1
72
+ `,[sqlDateTime(expiresAt),hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to create access token - inserted row not found");verifyAccessTokenInsert(row,{ownerId:userId,ownerType:tokenableType,clientId:client.id,scopes,expiresAt});const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name,scopes:parseScopes(row.scopes),revoked:!1,expiresAt,...tokenTimestamps(row)};let refreshTokenPlain;if(withRefreshToken){refreshTokenPlain=generateSecureToken(40);const hashedRefreshToken=hashToken(refreshTokenPlain),refreshExpiresAt=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
71
73
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
72
74
  VALUES ($1, $2, false, $3, ${appNow()})
73
75
  `,[accessToken.id,hashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
@@ -98,9 +100,11 @@ import{createHash,randomBytes}from"node:crypto";import{config}from"@stacksjs/con
98
100
  VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ${appNow()}, ${appNow()})
99
101
  `,[refreshRow.tokenable_type,refreshRow.tokenable_id,refreshRow.tokenable_id,refreshRow.oauth_client_id,hashedToken,refreshRow.name,refreshRow.scopes,sqlDateTime(expiresAt)]);const row=(await trx.unsafe(`
100
102
  SELECT id, user_id, tokenable_type, tokenable_id, oauth_client_id,
101
- name, scopes, revoked, expires_at, created_at, updated_at
102
- FROM oauth_access_tokens WHERE token = ${param(1)} LIMIT 1
103
- `,[hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");verifyAccessTokenInsert(row,{ownerId:refreshRow.tokenable_id,ownerType:refreshRow.tokenable_type,clientId:refreshRow.oauth_client_id,scopes:parseScopes(refreshRow.scopes),expiresAt});const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,...tokenTimestamps(row)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
103
+ name, scopes, revoked, expires_at,
104
+ (expires_at = ${param(1)}) AS expires_at_matches,
105
+ created_at, updated_at
106
+ FROM oauth_access_tokens WHERE token = ${param(2)} LIMIT 1
107
+ `,[sqlDateTime(expiresAt),hashedToken]))[0];if(!row)throw new HttpError(500,"Failed to read back the access token that was just created.");verifyAccessTokenInsert(row,{ownerId:refreshRow.tokenable_id,ownerType:refreshRow.tokenable_type,clientId:refreshRow.oauth_client_id,scopes:parseScopes(refreshRow.scopes),expiresAt});const accessToken={id:row.id,userId:row.user_id,clientId:row.oauth_client_id,name:row.name||"access-token",scopes:parseScopes(row.scopes),revoked:!1,expiresAt,...tokenTimestamps(row)};if(isIssuedBeforePasswordChange(row.created_at,await getPasswordChangedAt(refreshRow.tokenable_id,trx,refreshRow.tokenable_type??DEFAULT_TOKENABLE_TYPE)))throw new HttpError(401,"Invalid or expired refresh token");const newRefreshTokenPlain=generateSecureToken(40),newHashedRefreshToken=hashToken(newRefreshTokenPlain),refreshExpiresAt=refreshDeadline(refreshExpiresInDays);if(isPostgres)await trx.unsafe(`
104
108
  INSERT INTO oauth_refresh_tokens (access_token_id, token, revoked, expires_at, created_at)
105
109
  VALUES ($1, $2, false, $3, ${appNow()})
106
110
  `,[accessToken.id,newHashedRefreshToken,sqlDateTime(refreshExpiresAt)]);else await trx.unsafe(`
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/auth",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.182",
5
+ "version": "0.75.184",
6
6
  "description": "A more simplistic way to authenticate.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,18 +58,18 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@stacksjs/bun-router": "^0.1.24",
61
- "@stacksjs/cache": "0.75.182",
62
- "@stacksjs/config": "0.75.182",
63
- "@stacksjs/database": "0.75.182",
64
- "@stacksjs/email": "0.75.182",
65
- "@stacksjs/env": "0.75.182",
66
- "@stacksjs/error-handling": "0.75.182",
67
- "@stacksjs/logging": "0.75.182",
68
- "@stacksjs/orm": "0.75.182",
69
- "@stacksjs/path": "0.75.182",
70
- "@stacksjs/router": "0.75.182",
71
- "@stacksjs/security": "0.75.182",
72
- "@stacksjs/storage": "0.75.182",
61
+ "@stacksjs/cache": "0.75.184",
62
+ "@stacksjs/config": "0.75.184",
63
+ "@stacksjs/database": "0.75.184",
64
+ "@stacksjs/email": "0.75.184",
65
+ "@stacksjs/env": "0.75.184",
66
+ "@stacksjs/error-handling": "0.75.184",
67
+ "@stacksjs/logging": "0.75.184",
68
+ "@stacksjs/orm": "0.75.184",
69
+ "@stacksjs/path": "0.75.184",
70
+ "@stacksjs/router": "0.75.184",
71
+ "@stacksjs/security": "0.75.184",
72
+ "@stacksjs/storage": "0.75.184",
73
73
  "@stacksjs/ts-auth": "^0.4.5",
74
74
  "ts-qr-codes": "^0.1.8"
75
75
  },