@crombolo/auth 0.18.0 → 0.20.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/auth",
3
- "version": "0.18.0",
3
+ "version": "0.20.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.18.0",
32
- "@crombolo/data": "0.18.0",
31
+ "@crombolo/core": "0.20.0",
32
+ "@crombolo/data": "0.20.0",
33
33
  "jose": "^6.2.12",
34
34
  "uqr": "^0.1.3"
35
35
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './cookies.ts'
3
3
  export * from './device.ts'
4
4
  export * from './member-session.ts'
5
5
  export * from './oidc.ts'
6
+ export * from './origin.ts'
6
7
  export * from './password.ts'
7
8
  export * from './pkce.ts'
8
9
  export * from './routes.ts'
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)