@shipfox/api-auth 11.0.0 → 12.0.0

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.
Files changed (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +19 -19
  4. package/dist/core/job-lease-token.d.ts +1 -1
  5. package/dist/core/job-lease-token.d.ts.map +1 -1
  6. package/dist/core/job-lease-token.js +1 -1
  7. package/dist/core/job-lease-token.js.map +1 -1
  8. package/dist/core/runner-session-token.d.ts +1 -1
  9. package/dist/core/runner-session-token.d.ts.map +1 -1
  10. package/dist/core/runner-session-token.js +1 -1
  11. package/dist/core/runner-session-token.js.map +1 -1
  12. package/dist/db/rate-limits.d.ts +6 -15
  13. package/dist/db/rate-limits.d.ts.map +1 -1
  14. package/dist/db/rate-limits.js +17 -18
  15. package/dist/db/rate-limits.js.map +1 -1
  16. package/dist/db/refresh-tokens.d.ts +3 -3
  17. package/dist/db/refresh-tokens.js +3 -3
  18. package/dist/db/refresh-tokens.js.map +1 -1
  19. package/dist/presentation/auth/lease-token-auth.d.ts +1 -1
  20. package/dist/presentation/auth/lease-token-auth.js +1 -1
  21. package/dist/presentation/auth/lease-token-auth.js.map +1 -1
  22. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  23. package/dist/presentation/routes/rate-limit.js +32 -53
  24. package/dist/presentation/routes/rate-limit.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +13 -13
  27. package/src/core/job-lease-token.ts +1 -1
  28. package/src/core/runner-session-token.ts +1 -1
  29. package/src/db/rate-limits.ts +28 -42
  30. package/src/db/refresh-tokens.ts +3 -3
  31. package/src/presentation/auth/lease-token-auth.ts +1 -1
  32. package/src/presentation/routes/rate-limit.ts +31 -64
  33. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "11.0.0",
4
+ "version": "12.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,24 +26,24 @@
26
26
  "@node-rs/argon2": "^2.0.2",
27
27
  "drizzle-orm": "^0.45.2",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-common-dto": "9.2.0",
30
- "@shipfox/api-auth-context": "11.0.0",
31
- "@shipfox/api-auth-dto": "10.2.0",
32
- "@shipfox/api-email-challenges": "1.1.6",
33
- "@shipfox/api-workspaces-dto": "11.0.0",
34
- "@shipfox/inter-module": "0.2.2",
29
+ "@shipfox/api-common-dto": "12.0.0",
30
+ "@shipfox/api-auth-context": "12.0.0",
31
+ "@shipfox/api-auth-dto": "12.0.0",
32
+ "@shipfox/api-email-challenges": "1.1.7",
33
+ "@shipfox/api-workspaces-dto": "12.0.0",
34
+ "@shipfox/inter-module": "0.2.3",
35
35
  "@shipfox/config": "1.2.4",
36
- "@shipfox/node-drizzle": "0.3.4",
36
+ "@shipfox/node-drizzle": "0.3.5",
37
37
  "@shipfox/node-auth-root-key": "0.2.3",
38
- "@shipfox/node-fastify": "0.4.0",
39
- "@shipfox/node-jwt": "0.3.2",
38
+ "@shipfox/node-fastify": "0.4.1",
39
+ "@shipfox/node-jwt": "0.4.0",
40
40
  "@shipfox/node-email": "0.3.4",
41
41
  "@shipfox/node-mailer": "0.2.4",
42
- "@shipfox/node-module": "1.0.4",
42
+ "@shipfox/node-module": "1.0.5",
43
43
  "@shipfox/node-opentelemetry": "0.6.3",
44
44
  "@shipfox/node-outbox": "0.2.6",
45
- "@shipfox/node-postgres": "0.4.4",
46
- "@shipfox/node-rate-limit": "0.3.2",
45
+ "@shipfox/node-postgres": "0.5.0",
46
+ "@shipfox/node-rate-limit": "0.4.0",
47
47
  "@shipfox/node-tokens": "0.3.2"
48
48
  },
49
49
  "imports": {
@@ -66,7 +66,7 @@ export async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Prom
66
66
  return token;
67
67
  }
68
68
 
69
- /** Returns the claims on success, or `null` for any invalid input never throws. */
69
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
70
70
  export async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {
71
71
  try {
72
72
  const claims = await verifyHs256({
@@ -30,7 +30,7 @@ export async function issueRunnerSessionToken(
30
30
  return token;
31
31
  }
32
32
 
33
- /** Returns the claims on success, or `null` for any invalid input never throws. */
33
+ /** Returns the claims on success, or `null` for any invalid input. Never throws. */
34
34
  export async function verifyRunnerSessionToken(
35
35
  token: string,
36
36
  ): Promise<RunnerSessionTokenClaims | null> {
@@ -1,28 +1,23 @@
1
+ import {
2
+ type ConsumeRateLimitParams,
3
+ type ConsumeRateLimitResult,
4
+ createRateLimitPersistence,
5
+ } from '@shipfox/node-rate-limit';
1
6
  import {lt, sql} from 'drizzle-orm';
2
7
  import {db} from './db.js';
3
8
  import {authRateLimits} from './schema/rate-limits.js';
4
9
 
5
- export interface ConsumeAuthRateLimitParams {
6
- action: string;
7
- scope: string;
8
- identifierHmac: string;
9
- windowStart: Date;
10
- expiresAt: Date;
11
- timeoutMs: number;
12
- }
13
-
14
- export interface ConsumeAuthRateLimitResult {
15
- count: number;
16
- expiresAt: Date;
17
- }
18
-
19
- export async function consumeAuthRateLimit(
20
- params: ConsumeAuthRateLimitParams,
21
- ): Promise<ConsumeAuthRateLimitResult> {
22
- return await db().transaction(async (tx) => {
23
- await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
24
-
25
- const rows = await tx
10
+ type AuthRateLimitTransaction = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
11
+
12
+ const persistence = createRateLimitPersistence<AuthRateLimitTransaction>({
13
+ transaction: (callback) => db().transaction(callback),
14
+ setStatementTimeout: async (transaction, timeoutMs) => {
15
+ await transaction.execute(
16
+ sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
17
+ );
18
+ },
19
+ consume: async (transaction, params) => {
20
+ const rows = await transaction
26
21
  .insert(authRateLimits)
27
22
  .values({
28
23
  action: params.action,
@@ -46,24 +41,15 @@ export async function consumeAuthRateLimit(
46
41
  })
47
42
  .returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});
48
43
 
49
- const row = rows[0];
50
- if (!row) throw new Error('Rate limit upsert returned no rows');
51
- return row;
52
- });
53
- }
54
-
55
- let nextPruneAt = 0;
56
-
57
- export async function pruneExpiredAuthRateLimits(
58
- params: {now?: Date | undefined; minIntervalMs?: number | undefined} = {},
59
- ): Promise<number | undefined> {
60
- const now = params.now ?? new Date();
61
- const minIntervalMs = params.minIntervalMs ?? 60_000;
62
- if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
63
-
64
- nextPruneAt = now.getTime() + minIntervalMs;
65
-
66
- const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
67
-
68
- return result.rowCount ?? 0;
69
- }
44
+ return rows[0];
45
+ },
46
+ prune: async (now) => {
47
+ const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
48
+ return result.rowCount ?? 0;
49
+ },
50
+ });
51
+
52
+ export type ConsumeAuthRateLimitParams = ConsumeRateLimitParams<string, string>;
53
+ export type ConsumeAuthRateLimitResult = ConsumeRateLimitResult;
54
+ export const consumeAuthRateLimit = persistence.consume;
55
+ export const pruneExpiredAuthRateLimits = persistence.prune;
@@ -61,7 +61,7 @@ export async function createRefreshTokenForActiveUser(
61
61
  }
62
62
 
63
63
  /**
64
- * Looks up the live session token by hash one that can still authenticate as
64
+ * Looks up the live session token by hash. It can still authenticate as
65
65
  * the current session. Returns `undefined` for revoked, expired, or already
66
66
  * rotated tokens; rotated rows survive in the table only for the grace window
67
67
  * and are not "active".
@@ -90,8 +90,8 @@ export async function findActiveRefreshTokenByHash(params: {
90
90
  /**
91
91
  * Looks up a non-revoked, non-expired token by hash, including ones already
92
92
  * rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
93
- * rotate, honour the grace window, or reject reuse, so unlike
94
- * {@link findActiveRefreshTokenByHash} it must still see rotated rows.
93
+ * rotate, honour the grace window, or reject reuse, so, unlike
94
+ * {@link findActiveRefreshTokenByHash}, it must still see rotated rows.
95
95
  */
96
96
  export async function findRefreshTokenByHash(params: {
97
97
  hashedToken: string;
@@ -4,7 +4,7 @@ import {verifyJobLeaseToken} from '#core/job-lease-token.js';
4
4
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
5
5
 
6
6
  /**
7
- * Trust boundary: the signed token is the sole authority `claims.jobId` scopes
7
+ * Trust boundary: the signed token is the sole authority. `claims.jobId` scopes
8
8
  * every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
9
9
  * are carried for consumers but are NOT verified against the database here.
10
10
  *
@@ -1,10 +1,9 @@
1
1
  import {ClientError, type FastifyReply, type FastifyRequest} from '@shipfox/node-fastify';
2
+ import {enforceRateLimit as enforceSharedRateLimit} from '@shipfox/node-rate-limit';
2
3
  import {
3
4
  type AuthRateLimitAction,
4
- AuthRateLimitExceededError,
5
5
  type AuthRateLimitPolicy,
6
6
  type AuthRateLimitScope,
7
- AuthRateLimitUnavailableError,
8
7
  checkAuthRateLimit,
9
8
  } from '#core/rate-limit.js';
10
9
 
@@ -49,68 +48,36 @@ async function enforceRateLimit(params: {
49
48
  const policy = policies[params.action][params.scope];
50
49
  if (!policy) return;
51
50
 
52
- try {
53
- await checkAuthRateLimit({
54
- action: params.action,
55
- scope: params.scope,
56
- identifier: params.identifier,
57
- ...policy,
58
- });
59
- } catch (error) {
60
- if (error instanceof AuthRateLimitExceededError) {
61
- params.request.log.warn(
62
- {
63
- action: error.action,
64
- scope: error.scope,
65
- route: routeName(params.request),
66
- retryAfterSeconds: error.retryAfterSeconds,
67
- identifierHmacPrefix: error.identifierHmacPrefix,
68
- },
69
- 'Auth rate limit blocked request',
70
- );
71
- params.reply.header('Retry-After', String(error.retryAfterSeconds));
72
- throw new ClientError('Rate limit exceeded', 'rate-limited', {
73
- status: 429,
74
- details: {retry_after_seconds: error.retryAfterSeconds},
75
- data: {
76
- action: error.action,
77
- scope: error.scope,
78
- route: routeName(params.request),
79
- identifierHmacPrefix: error.identifierHmacPrefix,
80
- },
81
- cause: error,
82
- });
83
- }
84
-
85
- if (error instanceof AuthRateLimitUnavailableError) {
86
- params.request.log.error(
87
- {
88
- action: error.action,
89
- scope: error.scope,
90
- route: routeName(params.request),
91
- identifierHmacPrefix: error.identifierHmacPrefix,
92
- err: error,
93
- },
94
- 'Auth rate limiter unavailable',
95
- );
96
- throw new ClientError(
97
- 'Authentication rate limiter unavailable',
98
- 'auth-rate-limit-unavailable',
99
- {
100
- status: 503,
101
- data: {
102
- action: error.action,
103
- scope: error.scope,
104
- route: routeName(params.request),
105
- identifierHmacPrefix: error.identifierHmacPrefix,
106
- },
107
- cause: error,
108
- },
109
- );
110
- }
111
-
112
- throw error;
113
- }
51
+ await enforceSharedRateLimit({
52
+ request: params.request,
53
+ reply: params.reply,
54
+ check: () =>
55
+ checkAuthRateLimit({
56
+ action: params.action,
57
+ scope: params.scope,
58
+ identifier: params.identifier,
59
+ ...policy,
60
+ }),
61
+ route: routeName(params.request),
62
+ unavailableCode: 'auth-rate-limit-unavailable',
63
+ unavailableMessage: 'Authentication rate limiter unavailable',
64
+ setRetryAfter: (reply, retryAfterSeconds) => {
65
+ reply.header('Retry-After', String(retryAfterSeconds));
66
+ },
67
+ logWarn: (request, context) => {
68
+ request.log.warn(context, 'Auth rate limit blocked request');
69
+ },
70
+ logError: (request, context) => {
71
+ request.log.error(context, 'Auth rate limiter unavailable');
72
+ },
73
+ createClientError: (presentation, cause) =>
74
+ new ClientError(presentation.message, presentation.code, {
75
+ status: presentation.status,
76
+ ...(presentation.details ? {details: presentation.details} : {}),
77
+ data: presentation.data,
78
+ cause,
79
+ }),
80
+ });
114
81
  }
115
82
 
116
83
  export function createAuthRateLimitPreHandler(action: AuthRateLimitAction) {