@crombolo/auth 0.17.0 → 0.19.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/index.ts +1 -0
- package/src/origin.ts +32 -0
- package/src/routes.ts +20 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.19.0",
|
|
32
|
+
"@crombolo/data": "0.19.0",
|
|
33
33
|
"jose": "^6.2.12",
|
|
34
34
|
"uqr": "^0.1.3"
|
|
35
35
|
}
|
package/src/index.ts
CHANGED
package/src/origin.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-origin protection for cookie-authenticated writes. A browser marks every
|
|
3
|
+
* request with where it came from; a request carrying neither mark is not from a
|
|
4
|
+
* browser, so it cannot be a forged one and passes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
8
|
+
|
|
9
|
+
/** Whether a method changes nothing, so needs no origin check. */
|
|
10
|
+
export const isSafeMethod = (method: string): boolean => SAFE_METHODS.has(method.toUpperCase())
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether `request` came from this site rather than another origin — a sibling
|
|
14
|
+
* subdomain included. The `Origin` fallback compares hosts, since behind a proxy
|
|
15
|
+
* that ends TLS the request's own URL says `http` while the browser said `https`.
|
|
16
|
+
*/
|
|
17
|
+
export function isSameOrigin(request: Request, applicationUrl?: string): boolean {
|
|
18
|
+
const site = request.headers.get('sec-fetch-site')
|
|
19
|
+
if (site) return site === 'same-origin'
|
|
20
|
+
const origin = request.headers.get('origin')
|
|
21
|
+
if (origin === null) return true
|
|
22
|
+
let host: string
|
|
23
|
+
try {
|
|
24
|
+
host = new URL(origin).host
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
if (origin === new URL(request.url).origin) return true
|
|
29
|
+
if (applicationUrl && origin === new URL(applicationUrl).origin) return true
|
|
30
|
+
const forwarded = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim()
|
|
31
|
+
return host === (forwarded || request.headers.get('host'))
|
|
32
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
serializeCookie,
|
|
17
17
|
} from './cookies.ts'
|
|
18
18
|
import { REMOTE_CLIENT_ID } from './device.ts'
|
|
19
|
+
import { isSafeMethod, isSameOrigin } from './origin.ts'
|
|
19
20
|
import type { AuthService } from './service.ts'
|
|
20
21
|
import { BACKOFFICE_CLIENT_ID, DEFAULT_SCOPE } from './service.ts'
|
|
21
22
|
import { readShortLived, signShortLived } from './short-lived.ts'
|
|
@@ -33,6 +34,10 @@ export interface AuthRouteOptions {
|
|
|
33
34
|
backOfficePath: string
|
|
34
35
|
service: AuthService
|
|
35
36
|
cookies: CookieOptions
|
|
37
|
+
/** The site's public address, which a request's `Origin` may name behind a proxy. */
|
|
38
|
+
applicationUrl?: string
|
|
39
|
+
/** False refuses the password sign-in and its two-factor step, not merely hides the form. */
|
|
40
|
+
allowLocalLogin?: boolean
|
|
36
41
|
/** Told about every sign-in, failed sign-in and sign-out, for the audit log. */
|
|
37
42
|
onAudit?: (event: AuthAuditEvent, request: Request) => Promise<void>
|
|
38
43
|
/** Two-factor sign-in (story 032): who has a provider on, and whether a code is theirs. */
|
|
@@ -501,14 +506,6 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
501
506
|
return sessionId ? (await service.resolveSession(sessionId))?.userId : undefined
|
|
502
507
|
}
|
|
503
508
|
|
|
504
|
-
/** A cookie-authenticated write must come from this site, not a form elsewhere. */
|
|
505
|
-
const sameSite = (request: Request) => {
|
|
506
|
-
const site = request.headers.get('sec-fetch-site')
|
|
507
|
-
if (site) return site === 'same-origin'
|
|
508
|
-
const origin = request.headers.get('origin')
|
|
509
|
-
return !origin || origin === new URL(request.url).origin
|
|
510
|
-
}
|
|
511
|
-
|
|
512
509
|
const signInFirst = () => problemResponse(problemDetails({ title: 'Sign in first', status: 401 }))
|
|
513
510
|
|
|
514
511
|
/** What the approval page shows for a code, and its decision. */
|
|
@@ -537,7 +534,6 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
537
534
|
})
|
|
538
535
|
}
|
|
539
536
|
if (action === 'device/decide' && request.method === 'POST') {
|
|
540
|
-
if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
|
|
541
537
|
const body = (await request.json().catch(() => ({}))) as {
|
|
542
538
|
userCode?: unknown
|
|
543
539
|
approve?: unknown
|
|
@@ -564,7 +560,6 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
564
560
|
}
|
|
565
561
|
const revoking = /^devices\/(\d+)$/.exec(action)
|
|
566
562
|
if (revoking && request.method === 'DELETE') {
|
|
567
|
-
if (!sameSite(request)) return new Response('Forbidden', { status: 403 })
|
|
568
563
|
return (await service.revokeDevice(Number(revoking[1]), userId))
|
|
569
564
|
? new Response(null, { status: 204 })
|
|
570
565
|
: problemResponse(problemDetails({ title: 'No such device', status: 404 }))
|
|
@@ -577,9 +572,24 @@ export function createAuthRoutes(options: AuthRouteOptions): AuthRoutes {
|
|
|
577
572
|
if (!pathname.startsWith(`${prefix}/`)) return undefined
|
|
578
573
|
const action = pathname.slice(prefix.length + 1)
|
|
579
574
|
const url = new URL(request.url)
|
|
575
|
+
// Every write here reads a cookie or sets one, so none may come from another origin.
|
|
576
|
+
if (!isSafeMethod(request.method) && !isSameOrigin(request, options.applicationUrl))
|
|
577
|
+
return new Response('Forbidden', { status: 403 })
|
|
580
578
|
|
|
581
579
|
if (action === 'authorize' && request.method === 'GET') return handleAuthorize(request, url)
|
|
582
580
|
if (action === 'token' && request.method === 'POST') return handleToken(request, url)
|
|
581
|
+
if (
|
|
582
|
+
options.allowLocalLogin === false &&
|
|
583
|
+
(action === 'login' || action === 'verify-2fa') &&
|
|
584
|
+
request.method === 'POST'
|
|
585
|
+
)
|
|
586
|
+
return problemResponse(
|
|
587
|
+
problemDetails({
|
|
588
|
+
title: 'Signing in with a password is turned off',
|
|
589
|
+
status: 403,
|
|
590
|
+
detail: 'This site signs people in through its identity provider.',
|
|
591
|
+
}),
|
|
592
|
+
)
|
|
583
593
|
if (action === 'login' && request.method === 'POST') return handleLogin(request)
|
|
584
594
|
if (action === 'revoke' && request.method === 'POST') return handleRevoke(request)
|
|
585
595
|
if (action === 'signout') return handleSignOut(request, url)
|