@mantiq/oauth 0.5.23 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/oauth",
3
- "version": "0.5.23",
3
+ "version": "0.6.1",
4
4
  "description": "OAuth 2.0 server — authorization code (PKCE), client credentials, JWT access tokens, scopes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,19 +34,54 @@ export class AuthCodeGrant implements GrantHandler {
34
34
  const client = await Client.find(clientId)
35
35
  if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
36
36
 
37
- // Verify client secret for confidential clients
37
+ // Verify client secret for confidential clients.
38
+ // Uses bcrypt verification against the stored hash (not plaintext comparison).
38
39
  if (client.confidential()) {
39
- const storedSecret = client.getAttribute('secret') as string
40
- if (!clientSecret || !timingSafeEqual(clientSecret, storedSecret)) {
40
+ if (!clientSecret) {
41
41
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
42
42
  }
43
+ const secretValid = await client.verifySecret(clientSecret)
44
+ if (!secretValid) {
45
+ throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
46
+ }
47
+ }
48
+
49
+ // Security: validate redirect_uri against the client's registered redirect.
50
+ // This prevents an attacker from exchanging a stolen auth code with a
51
+ // different redirect_uri to intercept the tokens.
52
+ const allowedRedirect = client.getAttribute('redirect') as string
53
+ if (allowedRedirect) {
54
+ try {
55
+ const requestedUrl = new URL(redirectUri)
56
+ const allowedUrl = new URL(allowedRedirect)
57
+ if (
58
+ requestedUrl.origin !== allowedUrl.origin ||
59
+ requestedUrl.pathname !== allowedUrl.pathname ||
60
+ requestedUrl.search !== allowedUrl.search
61
+ ) {
62
+ throw new OAuthError('Redirect URI does not match the registered URI.', 'invalid_grant')
63
+ }
64
+ } catch (e) {
65
+ if (e instanceof OAuthError) throw e
66
+ throw new OAuthError('Invalid redirect URI format.', 'invalid_request')
67
+ }
43
68
  }
44
69
 
45
- // Resolve auth code
46
- const authCode = await AuthCode.where('id', code)
70
+ // Security: atomically revoke the auth code to prevent TOCTOU race conditions.
71
+ // A separate lookup + revoke creates a window where two concurrent requests
72
+ // could both read revoked=false and both exchange the same code for tokens.
73
+ // Instead, we UPDATE ... WHERE revoked=false and check affected rows.
74
+ const affected = await AuthCode.where('id', code)
47
75
  .where('revoked', false)
48
- .first() as AuthCode | null
76
+ .update({ revoked: true })
77
+
78
+ if (!affected || affected === 0) {
79
+ // Code was already used, doesn't exist, or was revoked
80
+ throw new OAuthError('Invalid authorization code.', 'invalid_grant')
81
+ }
49
82
 
83
+ // Now load the auth code to read its attributes (already revoked atomically above)
84
+ const authCode = await AuthCode.find(code) as AuthCode | null
50
85
  if (!authCode) throw new OAuthError('Invalid authorization code.', 'invalid_grant')
51
86
 
52
87
  // Verify the code belongs to this client
@@ -74,10 +109,6 @@ export class AuthCodeGrant implements GrantHandler {
74
109
  }
75
110
  }
76
111
 
77
- // Revoke the auth code (single use)
78
- authCode.setAttribute('revoked', true)
79
- await authCode.save()
80
-
81
112
  // Issue tokens
82
113
  const userId = authCode.getAttribute('user_id') as string
83
114
  const scopes = (authCode.getAttribute('scopes') as string[]) || []
@@ -158,20 +189,3 @@ export class AuthCodeGrant implements GrantHandler {
158
189
  throw new OAuthError(`Unsupported code challenge method: ${method}`, 'invalid_request')
159
190
  }
160
191
  }
161
-
162
- /**
163
- * Constant-time string comparison to prevent timing attacks on secret verification.
164
- */
165
- function timingSafeEqual(a: string, b: string): boolean {
166
- if (a.length !== b.length) return false
167
-
168
- const encoder = new TextEncoder()
169
- const bufA = encoder.encode(a)
170
- const bufB = encoder.encode(b)
171
-
172
- let result = 0
173
- for (let i = 0; i < bufA.length; i++) {
174
- result |= bufA[i]! ^ bufB[i]!
175
- }
176
- return result === 0
177
- }
@@ -30,9 +30,9 @@ export class ClientCredentialsGrant implements GrantHandler {
30
30
  const client = await Client.find(clientId)
31
31
  if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
32
32
 
33
- // Verify secret (constant-time comparison to prevent timing attacks)
34
- const storedSecret = client.getAttribute('secret') as string
35
- if (!timingSafeEqual(clientSecret, storedSecret)) {
33
+ // Verify secret (bcrypt verification against the stored hash)
34
+ const secretValid = await client.verifySecret(clientSecret)
35
+ if (!secretValid) {
36
36
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
37
37
  }
38
38
 
@@ -78,20 +78,3 @@ export class ClientCredentialsGrant implements GrantHandler {
78
78
  }
79
79
  }
80
80
  }
81
-
82
- /**
83
- * Constant-time string comparison to prevent timing attacks on secret verification.
84
- */
85
- function timingSafeEqual(a: string, b: string): boolean {
86
- if (a.length !== b.length) return false
87
-
88
- const encoder = new TextEncoder()
89
- const bufA = encoder.encode(a)
90
- const bufB = encoder.encode(b)
91
-
92
- let result = 0
93
- for (let i = 0; i < bufA.length; i++) {
94
- result |= bufA[i]! ^ bufB[i]!
95
- }
96
- return result === 0
97
- }
@@ -32,10 +32,13 @@ export class RefreshTokenGrant implements GrantHandler {
32
32
  const client = await Client.find(clientId)
33
33
  if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
34
34
 
35
- // Verify secret for confidential clients (constant-time comparison to prevent timing attacks)
35
+ // Verify secret for confidential clients (bcrypt verification against the stored hash)
36
36
  if (client.confidential()) {
37
- const storedSecret = client.getAttribute('secret') as string
38
- if (!clientSecret || !timingSafeEqual(clientSecret, storedSecret)) {
37
+ if (!clientSecret) {
38
+ throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
39
+ }
40
+ const secretValid = await client.verifySecret(clientSecret)
41
+ if (!secretValid) {
39
42
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
40
43
  }
41
44
  }
@@ -128,20 +131,3 @@ export class RefreshTokenGrant implements GrantHandler {
128
131
  }
129
132
  }
130
133
  }
131
-
132
- /**
133
- * Constant-time string comparison to prevent timing attacks on secret verification.
134
- */
135
- function timingSafeEqual(a: string, b: string): boolean {
136
- if (a.length !== b.length) return false
137
-
138
- const encoder = new TextEncoder()
139
- const bufA = encoder.encode(a)
140
- const bufB = encoder.encode(b)
141
-
142
- let result = 0
143
- for (let i = 0; i < bufA.length; i++) {
144
- result |= bufA[i]! ^ bufB[i]!
145
- }
146
- return result === 0
147
- }
@@ -35,4 +35,28 @@ export class Client extends Model {
35
35
  firstParty(): boolean {
36
36
  return !!this.getAttribute('personal_access_client')
37
37
  }
38
+
39
+ /**
40
+ * Hash the client secret before storing it.
41
+ * Should be called when creating or updating the client secret.
42
+ *
43
+ * Uses Bun.password.hash with bcrypt for secure one-way hashing.
44
+ * The plaintext secret is returned so it can be shown to the user once.
45
+ */
46
+ static async hashSecret(plaintext: string): Promise<string> {
47
+ return await Bun.password.hash(plaintext, { algorithm: 'bcrypt', cost: 10 })
48
+ }
49
+
50
+ /**
51
+ * Verify a plaintext secret against the stored hash.
52
+ *
53
+ * Uses Bun.password.verify for constant-time comparison against the
54
+ * bcrypt hash. Returns false if the secret is empty or null.
55
+ */
56
+ async verifySecret(plaintext: string): Promise<boolean> {
57
+ const storedHash = this.getAttribute('secret') as string | null
58
+ // Security: reject if no secret is stored (public client)
59
+ if (!storedHash || !plaintext) return false
60
+ return await Bun.password.verify(plaintext, storedHash)
61
+ }
38
62
  }
@@ -31,11 +31,11 @@ export class AuthorizationController {
31
31
  const client = await Client.find(clientId)
32
32
  if (!client) throw new OAuthError('Client not found.', 'invalid_client')
33
33
 
34
- // Validate redirect URI
34
+ // Security: validate redirect URI by parsing and comparing origin + pathname.
35
+ // Simple string comparison can be bypassed with trailing slashes, query
36
+ // params, or path traversal. We also require a registered redirect URI.
35
37
  const allowedRedirect = client.getAttribute('redirect') as string
36
- if (allowedRedirect && redirectUri !== allowedRedirect) {
37
- throw new OAuthError('Invalid redirect URI.', 'invalid_request')
38
- }
38
+ validateRedirectUri(redirectUri, allowedRedirect)
39
39
 
40
40
  const scopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
41
41
  const scopeDetails = scopes.map((s) => ({
@@ -78,11 +78,9 @@ export class AuthorizationController {
78
78
  const client = await Client.find(clientId)
79
79
  if (!client) throw new OAuthError('Client not found.', 'invalid_client')
80
80
 
81
- // Validate redirect URI matches client's registered URI (same check as GET)
81
+ // Security: strict redirect URI validation (origin + pathname comparison)
82
82
  const allowedRedirect = client.getAttribute('redirect') as string
83
- if (allowedRedirect && redirectUri !== allowedRedirect) {
84
- throw new OAuthError('Invalid redirect URI.', 'invalid_request')
85
- }
83
+ validateRedirectUri(redirectUri, allowedRedirect)
86
84
 
87
85
  const userId = typeof user.getAuthIdentifier === 'function'
88
86
  ? user.getAuthIdentifier()
@@ -127,14 +125,12 @@ export class AuthorizationController {
127
125
  if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
128
126
  if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
129
127
 
130
- // Validate redirect URI against client's registered URI (same check as approve)
128
+ // Validate redirect URI against client's registered URI
131
129
  const client = await Client.find(clientId)
132
130
  if (!client) throw new OAuthError('Client not found.', 'invalid_client')
133
131
 
134
132
  const allowedRedirect = client.getAttribute('redirect') as string
135
- if (allowedRedirect && redirectUri !== allowedRedirect) {
136
- throw new OAuthError('Invalid redirect URI.', 'invalid_request')
137
- }
133
+ validateRedirectUri(redirectUri, allowedRedirect)
138
134
 
139
135
  const url = new URL(redirectUri)
140
136
  url.searchParams.set('error', 'access_denied')
@@ -147,3 +143,55 @@ export class AuthorizationController {
147
143
  })
148
144
  }
149
145
  }
146
+
147
+ /**
148
+ * Strictly validate a redirect URI against the client's registered redirect.
149
+ *
150
+ * Security: parses both URIs and compares origin (scheme + host + port) and
151
+ * pathname. This prevents bypasses via trailing slashes, query params,
152
+ * fragments, or path traversal that simple string matching would miss.
153
+ *
154
+ * A registered redirect URI is required — clients without one are rejected.
155
+ */
156
+ function validateRedirectUri(requested: string, allowed: string): void {
157
+ if (!allowed) {
158
+ throw new OAuthError(
159
+ 'Client has no registered redirect URI.',
160
+ 'invalid_request',
161
+ )
162
+ }
163
+
164
+ let requestedUrl: URL
165
+ let allowedUrl: URL
166
+
167
+ try {
168
+ requestedUrl = new URL(requested)
169
+ } catch {
170
+ throw new OAuthError('Invalid redirect URI format.', 'invalid_request')
171
+ }
172
+
173
+ try {
174
+ allowedUrl = new URL(allowed)
175
+ } catch {
176
+ throw new OAuthError('Client has an invalid registered redirect URI.', 'server_error')
177
+ }
178
+
179
+ // Compare origin (scheme + host + port) and pathname strictly.
180
+ // Query params and fragments in the requested URI are rejected to
181
+ // prevent open-redirect attacks via appended parameters.
182
+ if (
183
+ requestedUrl.origin !== allowedUrl.origin ||
184
+ requestedUrl.pathname !== allowedUrl.pathname
185
+ ) {
186
+ throw new OAuthError('Invalid redirect URI.', 'invalid_request')
187
+ }
188
+
189
+ // Reject if the requested URI has extra query params or fragments
190
+ // that differ from the registered one.
191
+ if (requestedUrl.search !== allowedUrl.search) {
192
+ throw new OAuthError(
193
+ 'Redirect URI query parameters do not match the registered URI.',
194
+ 'invalid_request',
195
+ )
196
+ }
197
+ }