@mantiq/oauth 0.5.21 → 0.5.23

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.21",
3
+ "version": "0.5.23",
4
4
  "description": "OAuth 2.0 server — authorization code (PKCE), client credentials, JWT access tokens, scopes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,14 +3,17 @@ import { HttpError } from '@mantiq/core'
3
3
  /**
4
4
  * OAuth-specific error.
5
5
  * Defaults to 400 Bad Request. Use a different status code for specific cases.
6
+ *
7
+ * The `errorCode` property carries the OAuth2 error code
8
+ * (e.g. 'invalid_request', 'invalid_grant') as defined by RFC 6749.
6
9
  */
7
10
  export class OAuthError extends HttpError {
8
11
  constructor(
9
12
  message: string,
10
- public readonly errorCode: string = 'invalid_request',
13
+ oauthCode: string = 'invalid_request',
11
14
  statusCode = 400,
12
15
  ) {
13
- super(statusCode, message)
16
+ super(statusCode, message, undefined, undefined, oauthCode)
14
17
  }
15
18
 
16
19
  toJSON(): Record<string, any> {
@@ -36,7 +36,8 @@ export class AuthCodeGrant implements GrantHandler {
36
36
 
37
37
  // Verify client secret for confidential clients
38
38
  if (client.confidential()) {
39
- if (!clientSecret || clientSecret !== client.getAttribute('secret')) {
39
+ const storedSecret = client.getAttribute('secret') as string
40
+ if (!clientSecret || !timingSafeEqual(clientSecret, storedSecret)) {
40
41
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
41
42
  }
42
43
  }
@@ -157,3 +158,20 @@ export class AuthCodeGrant implements GrantHandler {
157
158
  throw new OAuthError(`Unsupported code challenge method: ${method}`, 'invalid_request')
158
159
  }
159
160
  }
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,8 +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
34
- if (clientSecret !== client.getAttribute('secret')) {
33
+ // Verify secret (constant-time comparison to prevent timing attacks)
34
+ const storedSecret = client.getAttribute('secret') as string
35
+ if (!timingSafeEqual(clientSecret, storedSecret)) {
35
36
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
36
37
  }
37
38
 
@@ -77,3 +78,20 @@ export class ClientCredentialsGrant implements GrantHandler {
77
78
  }
78
79
  }
79
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,9 +32,10 @@ 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
35
+ // Verify secret for confidential clients (constant-time comparison to prevent timing attacks)
36
36
  if (client.confidential()) {
37
- if (!clientSecret || clientSecret !== client.getAttribute('secret')) {
37
+ const storedSecret = client.getAttribute('secret') as string
38
+ if (!clientSecret || !timingSafeEqual(clientSecret, storedSecret)) {
38
39
  throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
39
40
  }
40
41
  }
@@ -127,3 +128,20 @@ export class RefreshTokenGrant implements GrantHandler {
127
128
  }
128
129
  }
129
130
  }
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
+ }
@@ -78,6 +78,12 @@ 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)
82
+ const allowedRedirect = client.getAttribute('redirect') as string
83
+ if (allowedRedirect && redirectUri !== allowedRedirect) {
84
+ throw new OAuthError('Invalid redirect URI.', 'invalid_request')
85
+ }
86
+
81
87
  const userId = typeof user.getAuthIdentifier === 'function'
82
88
  ? user.getAuthIdentifier()
83
89
  : user.id ?? user.getAttribute?.('id')
@@ -114,11 +120,22 @@ export class AuthorizationController {
114
120
  * Deny the authorization request.
115
121
  */
116
122
  async deny(request: MantiqRequest): Promise<Response> {
123
+ const clientId = await request.input('client_id') as string | undefined
117
124
  const redirectUri = await request.input('redirect_uri') as string | undefined
118
125
  const state = await request.input('state') as string | undefined
119
126
 
127
+ if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
120
128
  if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
121
129
 
130
+ // Validate redirect URI against client's registered URI (same check as approve)
131
+ const client = await Client.find(clientId)
132
+ if (!client) throw new OAuthError('Client not found.', 'invalid_client')
133
+
134
+ const allowedRedirect = client.getAttribute('redirect') as string
135
+ if (allowedRedirect && redirectUri !== allowedRedirect) {
136
+ throw new OAuthError('Invalid redirect URI.', 'invalid_request')
137
+ }
138
+
122
139
  const url = new URL(redirectUri)
123
140
  url.searchParams.set('error', 'access_denied')
124
141
  url.searchParams.set('error_description', 'The user denied the authorization request.')