@crombolo/auth 0.10.0 → 0.11.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 +3 -3
- package/src/routes.ts +49 -3
- package/src/service.ts +21 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.11.0",
|
|
32
|
+
"@crombolo/data": "0.11.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './cookies.ts'
|
|
18
18
|
import type { AuthService } from './service.ts'
|
|
19
19
|
import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
|
|
20
|
+
import type { UserRow } from './store.ts'
|
|
20
21
|
|
|
21
22
|
export const SECURITY_PREFIX = `${MANAGEMENT_API_PATH}/security/back-office`
|
|
22
23
|
|
|
@@ -24,6 +25,17 @@ export interface AuthRouteOptions {
|
|
|
24
25
|
backOfficePath: string
|
|
25
26
|
service: AuthService
|
|
26
27
|
cookies: CookieOptions
|
|
28
|
+
/** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
|
|
29
|
+
onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuthAuditEvent {
|
|
33
|
+
action: 'SignIn' | 'SignInFailed' | 'LockedOut' | 'SignOut'
|
|
34
|
+
/** Absent when the login named nobody. */
|
|
35
|
+
user?: Pick<UserRow, 'key' | 'userName'>
|
|
36
|
+
/** The login that was tried, for a failure that names nobody. */
|
|
37
|
+
login?: string
|
|
38
|
+
detail?: string
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export interface AuthRoutes {
|
|
@@ -47,6 +59,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
47
59
|
const names = cookieNames(options.cookies)
|
|
48
60
|
const secure = options.cookies.securePrefix
|
|
49
61
|
const { service } = options
|
|
62
|
+
const audit = (event: AuthAuditEvent, request: Request) =>
|
|
63
|
+
options.onAudit?.(event, request) ?? Promise.resolve()
|
|
50
64
|
|
|
51
65
|
const expectedRedirectUri = (origin: string) =>
|
|
52
66
|
`${origin}${options.backOfficePath}/oauth_complete`
|
|
@@ -144,7 +158,14 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
144
158
|
// API users: a machine client presents its own id and secret and gets a bearer token back
|
|
145
159
|
if (grantType === 'client_credentials') {
|
|
146
160
|
const issued = await service.clientCredentials(clientId, form.get('client_secret') ?? '')
|
|
147
|
-
if (!issued.ok)
|
|
161
|
+
if (!issued.ok) {
|
|
162
|
+
await audit(
|
|
163
|
+
{ action: 'SignInFailed', login: clientId, detail: 'Client credentials refused' },
|
|
164
|
+
request,
|
|
165
|
+
)
|
|
166
|
+
return oauthError(issued.error, issued.description, 401)
|
|
167
|
+
}
|
|
168
|
+
await audit({ action: 'SignIn', user: issued.user, detail: 'Client credentials' }, request)
|
|
148
169
|
return Response.json({
|
|
149
170
|
access_token: issued.accessToken,
|
|
150
171
|
token_type: 'Bearer',
|
|
@@ -262,6 +283,29 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
262
283
|
request.headers.get('x-forwarded-for') ?? undefined,
|
|
263
284
|
)
|
|
264
285
|
|
|
286
|
+
if (result.status === 'success') await audit({ action: 'SignIn', user: result.user }, request)
|
|
287
|
+
else if (result.status === 'invalid') {
|
|
288
|
+
await audit(
|
|
289
|
+
result.user
|
|
290
|
+
? { action: 'SignInFailed', user: result.user, detail: 'Wrong password' }
|
|
291
|
+
: { action: 'SignInFailed', login: username, detail: 'No such user' },
|
|
292
|
+
request,
|
|
293
|
+
)
|
|
294
|
+
if (result.lockedOut && result.user)
|
|
295
|
+
await audit(
|
|
296
|
+
{ action: 'LockedOut', user: result.user, detail: 'Too many failed attempts' },
|
|
297
|
+
request,
|
|
298
|
+
)
|
|
299
|
+
} else if (result.status === 'notAllowed')
|
|
300
|
+
await audit(
|
|
301
|
+
{
|
|
302
|
+
action: 'SignInFailed',
|
|
303
|
+
...(result.user ? { user: result.user } : { login: username }),
|
|
304
|
+
detail: result.reason === 'notApproved' ? 'Account disabled' : 'Account locked',
|
|
305
|
+
},
|
|
306
|
+
request,
|
|
307
|
+
)
|
|
308
|
+
|
|
265
309
|
// Status codes the login UI branches on: 401 invalid, 402 needs 2FA,
|
|
266
310
|
// 403 locked or not approved.
|
|
267
311
|
if (result.status === 'invalid') {
|
|
@@ -296,7 +340,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
296
340
|
async function handleRevoke(request: Request): Promise<Response> {
|
|
297
341
|
const form = new URLSearchParams(await request.text())
|
|
298
342
|
const token = resolveRedacted(form.get('token'), readCookie(request, names.accessToken))
|
|
299
|
-
|
|
343
|
+
const revoked = token ? await service.revokeAccessToken(token) : undefined
|
|
344
|
+
if (revoked) await audit({ action: 'SignOut', user: revoked }, request)
|
|
300
345
|
const response = new Response(null, { status: 200 })
|
|
301
346
|
for (const cookie of clearedCookies()) response.headers.append('set-cookie', cookie)
|
|
302
347
|
return response
|
|
@@ -304,7 +349,8 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
304
349
|
|
|
305
350
|
async function handleSignOut(request: Request, url: URL): Promise<Response> {
|
|
306
351
|
const sessionId = readCookie(request, names.session)
|
|
307
|
-
|
|
352
|
+
const ended = sessionId ? await service.signOut(sessionId) : undefined
|
|
353
|
+
if (ended) await audit({ action: 'SignOut', user: ended }, request)
|
|
308
354
|
|
|
309
355
|
const requested = url.searchParams.get('post_logout_redirect_uri')
|
|
310
356
|
const allowed = `${url.origin}${options.backOfficePath}/logout`
|
package/src/service.ts
CHANGED
|
@@ -38,11 +38,15 @@ export const DEFAULT_AUTH_SETTINGS: AuthSettings = {
|
|
|
38
38
|
maxFailedLoginAttempts: 10,
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `user` on a refusal is for the audit log only, and never reaches the response:
|
|
43
|
+
* the status alone is what the sign-in page may learn.
|
|
44
|
+
*/
|
|
41
45
|
export type LoginResult =
|
|
42
46
|
| { status: 'success'; user: UserRow; sessionId: string }
|
|
43
|
-
| { status: 'invalid' }
|
|
47
|
+
| { status: 'invalid'; user?: UserRow; lockedOut?: boolean }
|
|
44
48
|
| { status: 'twoFactorRequired'; providers: string[] }
|
|
45
|
-
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved' }
|
|
49
|
+
| { status: 'notAllowed'; reason: 'disabled' | 'lockedOut' | 'notApproved'; user?: UserRow }
|
|
46
50
|
|
|
47
51
|
export interface AuthorizationRequest {
|
|
48
52
|
clientId: string
|
|
@@ -125,22 +129,20 @@ export class AuthService {
|
|
|
125
129
|
// this path costing what the others cost.
|
|
126
130
|
if (user.isLockedOut) {
|
|
127
131
|
await verifyPassword(password, await DUMMY_HASH_PROMISE)
|
|
128
|
-
return { status: 'notAllowed', reason: 'lockedOut' } as const
|
|
132
|
+
return { status: 'notAllowed', reason: 'lockedOut', user } as const
|
|
129
133
|
}
|
|
130
134
|
if (!(await verifyPassword(password, user.passwordHash))) {
|
|
131
135
|
const attempts = await this.#store.recordLoginFailure(user.id)
|
|
132
|
-
|
|
136
|
+
const lockedOut =
|
|
133
137
|
this.#settings.maxFailedLoginAttempts > 0 &&
|
|
134
138
|
attempts >= this.#settings.maxFailedLoginAttempts
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
return { status: 'invalid' } as const
|
|
139
|
+
if (lockedOut) await this.#store.lockOut(user.id)
|
|
140
|
+
return { status: 'invalid', user, lockedOut } as const
|
|
139
141
|
}
|
|
140
142
|
// `isApproved` stays behind the password on purpose: it is not a state an
|
|
141
143
|
// attacker can induce, so answering it only to whoever knows the password
|
|
142
144
|
// discloses strictly less than checking it up front.
|
|
143
|
-
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved' } as const
|
|
145
|
+
if (!user.isApproved) return { status: 'notAllowed', reason: 'notApproved', user } as const
|
|
144
146
|
|
|
145
147
|
const sessionId = crypto.randomUUID()
|
|
146
148
|
await this.#store.recordLoginSuccess(user.id, sessionId, ipAddress)
|
|
@@ -247,7 +249,7 @@ export class AuthService {
|
|
|
247
249
|
clientId: string,
|
|
248
250
|
secret: string,
|
|
249
251
|
): Promise<
|
|
250
|
-
| { ok: true; accessToken: string; expiresIn: number; issuedAt: number }
|
|
252
|
+
| { ok: true; accessToken: string; expiresIn: number; issuedAt: number; user: UserRow }
|
|
251
253
|
| { ok: false; error: TokenError; description: string }
|
|
252
254
|
> {
|
|
253
255
|
const credential = await this.#store.findClientCredential(clientId)
|
|
@@ -273,6 +275,7 @@ export class AuthService {
|
|
|
273
275
|
accessToken,
|
|
274
276
|
expiresIn: this.#settings.accessTokenSeconds,
|
|
275
277
|
issuedAt: Math.floor(now / 1000),
|
|
278
|
+
user,
|
|
276
279
|
}
|
|
277
280
|
}
|
|
278
281
|
|
|
@@ -301,13 +304,18 @@ export class AuthService {
|
|
|
301
304
|
return this.#store.findSession(sessionId)
|
|
302
305
|
}
|
|
303
306
|
|
|
304
|
-
|
|
307
|
+
/** Ends the session, and returns whose it was while it was still open. */
|
|
308
|
+
async signOut(sessionId: string): Promise<UserRow | undefined> {
|
|
309
|
+
const session = await this.#store.findSession(sessionId)
|
|
310
|
+
const user = session ? await this.#store.findUserById(session.userId) : undefined
|
|
305
311
|
await this.#store.endSession(sessionId)
|
|
312
|
+
return user
|
|
306
313
|
}
|
|
307
314
|
|
|
308
|
-
async revokeAccessToken(accessToken: string): Promise<
|
|
315
|
+
async revokeAccessToken(accessToken: string): Promise<UserRow | undefined> {
|
|
309
316
|
const token = await this.#store.findToken(accessToken, 'access')
|
|
310
|
-
if (token)
|
|
317
|
+
if (!token) return undefined
|
|
318
|
+
return this.signOut(token.sessionId)
|
|
311
319
|
}
|
|
312
320
|
|
|
313
321
|
async #issue(
|