@mantiq/oauth 0.5.23 → 0.6.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.
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
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
|
-
.
|
|
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 (
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
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 (
|
|
35
|
+
// Verify secret for confidential clients (bcrypt verification against the stored hash)
|
|
36
36
|
if (client.confidential()) {
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
}
|
package/src/models/Client.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
81
|
+
// Security: strict redirect URI validation (origin + pathname comparison)
|
|
82
82
|
const allowedRedirect = client.getAttribute('redirect') as string
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|