@crombolo/auth 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/admin.ts +48 -0
- package/src/cookies.ts +91 -0
- package/src/index.ts +8 -0
- package/src/member-session.ts +94 -0
- package/src/password.ts +33 -0
- package/src/pkce.ts +49 -0
- package/src/routes.ts +338 -0
- package/src/service.ts +354 -0
- package/src/store.ts +391 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "OAuth2 authorization server, password hashing, cookies and member tickets for crombolo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/auth"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"NOTICE"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@crombolo/core": "0.9.1",
|
|
32
|
+
"@crombolo/data": "0.9.1"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Administrator account maintenance.
|
|
3
|
+
*
|
|
4
|
+
* A seeded password is printed once, on the run that creates the account, and
|
|
5
|
+
* seeding is idempotent — so without this there is no way back into a database
|
|
6
|
+
* whose generated password has scrolled away.
|
|
7
|
+
*/
|
|
8
|
+
import { hashPassword, passwordConfigJson, verifyPassword } from './password.ts'
|
|
9
|
+
import { generateToken } from './pkce.ts'
|
|
10
|
+
import type { AuthStore } from './store.ts'
|
|
11
|
+
|
|
12
|
+
export interface EnsuredAdmin {
|
|
13
|
+
login: string
|
|
14
|
+
password: string
|
|
15
|
+
/** False when the account already existed and only its password was replaced. */
|
|
16
|
+
created: boolean
|
|
17
|
+
/** Sessions invalidated by the password change. */
|
|
18
|
+
sessionsEnded: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A readable but high-entropy password for local development. */
|
|
22
|
+
export function generatePassword(): string {
|
|
23
|
+
return generateToken(12).replaceAll('-', '').replaceAll('_', '').slice(0, 16)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Guarantees that `login` can sign in with the returned password, clearing any
|
|
28
|
+
* lockout on the way. A changed password ends existing sessions, because a change
|
|
29
|
+
* that leaves old tokens working is not a change; an unchanged one keeps them, so
|
|
30
|
+
* restarting with a configured password does not sign editors out.
|
|
31
|
+
*/
|
|
32
|
+
export async function resetAdminPassword(
|
|
33
|
+
store: AuthStore,
|
|
34
|
+
login: string,
|
|
35
|
+
password = generatePassword(),
|
|
36
|
+
): Promise<EnsuredAdmin | undefined> {
|
|
37
|
+
const user = await store.findUserByLogin(login)
|
|
38
|
+
if (!user) return undefined
|
|
39
|
+
|
|
40
|
+
await store.enable(user.id)
|
|
41
|
+
if (await verifyPassword(password, user.passwordHash))
|
|
42
|
+
return { login: user.login, password, created: false, sessionsEnded: 0 }
|
|
43
|
+
|
|
44
|
+
await store.setPassword(user.id, await hashPassword(password), passwordConfigJson())
|
|
45
|
+
const sessionsEnded = await store.endAllSessionsForUser(user.id)
|
|
46
|
+
|
|
47
|
+
return { login: user.login, password, created: false, sessionsEnded }
|
|
48
|
+
}
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie handling for reference tokens.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco v17+ never returns real token values to the browser: the token
|
|
5
|
+
* endpoint's JSON body carries `[redacted]`, and the real values live in
|
|
6
|
+
* `__Host-`-prefixed httpOnly cookies. The client sends `credentials: include`
|
|
7
|
+
* and `refresh_token=[redacted]`, and the server substitutes the real value.
|
|
8
|
+
*
|
|
9
|
+
* The `__Host-` prefix requires Secure and `Path=/` with no Domain. Browsers
|
|
10
|
+
* treat http://localhost as a secure context, so it works in local development,
|
|
11
|
+
* but a site served over plain http on another hostname must drop the prefix —
|
|
12
|
+
* hence `securePrefix`.
|
|
13
|
+
*/
|
|
14
|
+
export const REDACTED = '[redacted]'
|
|
15
|
+
|
|
16
|
+
export interface CookieNames {
|
|
17
|
+
accessToken: string
|
|
18
|
+
refreshToken: string
|
|
19
|
+
session: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CookieOptions {
|
|
23
|
+
/** Distinguishes cookies when several sites share a host. */
|
|
24
|
+
siteName: string
|
|
25
|
+
/** False drops the `__Host-` prefix and the Secure flag, for plain-http dev. */
|
|
26
|
+
securePrefix: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cookieNames(options: CookieOptions): CookieNames {
|
|
30
|
+
const prefix = options.securePrefix ? '__Host-' : ''
|
|
31
|
+
return {
|
|
32
|
+
accessToken: `${prefix}umbAccessToken${options.siteName}`,
|
|
33
|
+
refreshToken: `${prefix}umbRefreshToken${options.siteName}`,
|
|
34
|
+
session: `${prefix}umbSession${options.siteName}`,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseCookies(header: string | null): Map<string, string> {
|
|
39
|
+
const out = new Map<string, string>()
|
|
40
|
+
if (!header) return out
|
|
41
|
+
for (const part of header.split(';')) {
|
|
42
|
+
const index = part.indexOf('=')
|
|
43
|
+
if (index < 1) continue
|
|
44
|
+
const name = part.slice(0, index).trim()
|
|
45
|
+
const value = part.slice(index + 1).trim()
|
|
46
|
+
if (name) out.set(name, decodeURIComponent(value))
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readCookie(request: Request, name: string): string | undefined {
|
|
52
|
+
return parseCookies(request.headers.get('cookie')).get(name)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SetCookieInit {
|
|
56
|
+
name: string
|
|
57
|
+
value: string
|
|
58
|
+
maxAgeSeconds?: number
|
|
59
|
+
secure: boolean
|
|
60
|
+
/** Session cookies are scoped to the backoffice; tokens must be Path=/ for `__Host-`. */
|
|
61
|
+
path?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function serializeCookie(init: SetCookieInit): string {
|
|
65
|
+
const parts = [
|
|
66
|
+
`${init.name}=${encodeURIComponent(init.value)}`,
|
|
67
|
+
`Path=${init.path ?? '/'}`,
|
|
68
|
+
'HttpOnly',
|
|
69
|
+
'SameSite=Lax',
|
|
70
|
+
]
|
|
71
|
+
if (init.secure) parts.push('Secure')
|
|
72
|
+
if (init.maxAgeSeconds !== undefined) parts.push(`Max-Age=${Math.max(0, init.maxAgeSeconds)}`)
|
|
73
|
+
return parts.join('; ')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function expireCookie(name: string, secure: boolean, path = '/'): string {
|
|
77
|
+
return serializeCookie({ name, value: '', maxAgeSeconds: 0, secure, path })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Replaces a token value the client sent as `[redacted]` with the real one from
|
|
82
|
+
* the cookie. A value that is not the redaction placeholder is returned as-is, so
|
|
83
|
+
* a non-browser client can still present a real token.
|
|
84
|
+
*/
|
|
85
|
+
export function resolveRedacted(
|
|
86
|
+
submitted: string | null | undefined,
|
|
87
|
+
cookieValue: string | undefined,
|
|
88
|
+
): string | undefined {
|
|
89
|
+
if (submitted && submitted !== REDACTED) return submitted
|
|
90
|
+
return cookieValue
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cookie a signed-in member carries on the front end.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco authenticates members with ASP.NET Identity's application cookie, and
|
|
5
|
+
* revokes one by rotating the member's security stamp. This is the same design
|
|
6
|
+
* without the framework: a signed ticket naming the member, its expiry and the
|
|
7
|
+
* stamp it was issued against. Nothing is stored server side, so a load-balanced
|
|
8
|
+
* set needs only the shared signing key — and because the stamp is checked on
|
|
9
|
+
* every request, changing a password or locking an account ends every session it
|
|
10
|
+
* had, immediately.
|
|
11
|
+
*
|
|
12
|
+
* The ticket is signed, not encrypted: it carries no secret, and a member reading
|
|
13
|
+
* their own key learns nothing. Tampering is what matters, and HMAC-SHA256 over
|
|
14
|
+
* the payload is what stops it.
|
|
15
|
+
*/
|
|
16
|
+
const ENCODER = new TextEncoder()
|
|
17
|
+
|
|
18
|
+
export interface MemberTicket {
|
|
19
|
+
/** The member's key. */
|
|
20
|
+
id: string
|
|
21
|
+
/** The security stamp the ticket was issued against. */
|
|
22
|
+
stamp: string
|
|
23
|
+
/** Seconds since the epoch. */
|
|
24
|
+
expires: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const base64url = (bytes: Uint8Array): string =>
|
|
28
|
+
btoa(String.fromCharCode(...bytes))
|
|
29
|
+
.replaceAll('+', '-')
|
|
30
|
+
.replaceAll('/', '_')
|
|
31
|
+
.replace(/=+$/, '')
|
|
32
|
+
|
|
33
|
+
const fromBase64url = (value: string): Uint8Array => {
|
|
34
|
+
const padded = value.replaceAll('-', '+').replaceAll('_', '/')
|
|
35
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
|
36
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function key(secret: string): Promise<CryptoKey> {
|
|
40
|
+
return crypto.subtle.importKey(
|
|
41
|
+
'raw',
|
|
42
|
+
ENCODER.encode(secret),
|
|
43
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
44
|
+
false,
|
|
45
|
+
['sign', 'verify'],
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A signed ticket: `<payload>.<signature>`, both base64url. */
|
|
50
|
+
export async function signMemberTicket(secret: string, ticket: MemberTicket): Promise<string> {
|
|
51
|
+
const payload = base64url(ENCODER.encode(JSON.stringify(ticket)))
|
|
52
|
+
const signature = await crypto.subtle.sign('HMAC', await key(secret), ENCODER.encode(payload))
|
|
53
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads a ticket, or undefined when it is malformed, unsigned by us, or expired.
|
|
58
|
+
* Whether the stamp still matches is the caller's business — only it can look
|
|
59
|
+
* the member up.
|
|
60
|
+
*/
|
|
61
|
+
export async function readMemberTicket(
|
|
62
|
+
secret: string,
|
|
63
|
+
value: string | undefined,
|
|
64
|
+
now: Date = new Date(),
|
|
65
|
+
): Promise<MemberTicket | undefined> {
|
|
66
|
+
if (!value) return undefined
|
|
67
|
+
const [payload, signature] = value.split('.')
|
|
68
|
+
if (!payload || !signature) return undefined
|
|
69
|
+
let valid = false
|
|
70
|
+
try {
|
|
71
|
+
valid = await crypto.subtle.verify(
|
|
72
|
+
'HMAC',
|
|
73
|
+
await key(secret),
|
|
74
|
+
fromBase64url(signature) as unknown as ArrayBuffer,
|
|
75
|
+
ENCODER.encode(payload),
|
|
76
|
+
)
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
if (!valid) return undefined
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload))) as MemberTicket
|
|
83
|
+
if (typeof parsed?.id !== 'string' || typeof parsed?.expires !== 'number') return undefined
|
|
84
|
+
if (parsed.expires * 1000 <= now.getTime()) return undefined
|
|
85
|
+
return parsed
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The cookie name, alongside the backoffice's own so the two never collide. */
|
|
92
|
+
export function memberCookieName(options: { siteName: string; securePrefix: boolean }): string {
|
|
93
|
+
return `${options.securePrefix ? '__Host-' : ''}umbMember${options.siteName}`
|
|
94
|
+
}
|
package/src/password.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing.
|
|
3
|
+
*
|
|
4
|
+
* Umbraco stores ASP.NET Core Identity v3 hashes (PBKDF2-HMAC-SHA512). We are not
|
|
5
|
+
* reading existing Umbraco databases, so we use argon2id instead, and keep the
|
|
6
|
+
* `password_config` marker column so the algorithm is recorded per row and can be
|
|
7
|
+
* rotated later exactly as Umbraco rotates its legacy formats.
|
|
8
|
+
*/
|
|
9
|
+
export const PASSWORD_ALGORITHM = 'ARGON2ID'
|
|
10
|
+
|
|
11
|
+
export interface PasswordConfig {
|
|
12
|
+
hashAlgorithm: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const passwordConfigJson = (algorithm = PASSWORD_ALGORITHM): string =>
|
|
16
|
+
JSON.stringify({ hashAlgorithm: algorithm } satisfies PasswordConfig)
|
|
17
|
+
|
|
18
|
+
export async function hashPassword(password: string): Promise<string> {
|
|
19
|
+
return Bun.password.hash(password, { algorithm: 'argon2id' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Verifies a password. Returns false rather than throwing for a malformed or
|
|
24
|
+
* absent hash, so a user row with no password simply cannot sign in.
|
|
25
|
+
*/
|
|
26
|
+
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
|
|
27
|
+
if (!hash) return false
|
|
28
|
+
try {
|
|
29
|
+
return await Bun.password.verify(password, hash)
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636). The client generates a 128-character verifier and sends us
|
|
3
|
+
* the S256 challenge; we verify the verifier against it at token exchange.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
export type CodeChallengeMethod = 'S256' | 'plain'
|
|
8
|
+
|
|
9
|
+
export function base64UrlEncode(bytes: Uint8Array | Buffer): string {
|
|
10
|
+
return Buffer.from(bytes).toString('base64url')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function deriveChallenge(verifier: string): string {
|
|
14
|
+
return base64UrlEncode(createHash('sha256').update(verifier).digest())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `plain` is accepted only because the spec defines it; the backoffice always
|
|
19
|
+
* sends S256, so a plain challenge in practice means something is wrong.
|
|
20
|
+
*/
|
|
21
|
+
export function verifyChallenge(
|
|
22
|
+
verifier: string,
|
|
23
|
+
challenge: string,
|
|
24
|
+
method: CodeChallengeMethod,
|
|
25
|
+
): boolean {
|
|
26
|
+
const expected = method === 'S256' ? deriveChallenge(verifier) : verifier
|
|
27
|
+
return timingSafeEqual(expected, challenge)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function timingSafeEqual(a: string, b: string): boolean {
|
|
31
|
+
if (a.length !== b.length) return false
|
|
32
|
+
let mismatch = 0
|
|
33
|
+
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
34
|
+
return mismatch === 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Opaque, high-entropy credential: authorization codes and reference tokens. */
|
|
38
|
+
export function generateToken(bytes = 48): string {
|
|
39
|
+
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(bytes)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Credentials are stored hashed so a database read cannot yield a usable token.
|
|
44
|
+
* SHA-256 is right here, not argon2: these are high-entropy random values, not
|
|
45
|
+
* guessable secrets, and validation happens on every request.
|
|
46
|
+
*/
|
|
47
|
+
export function hashToken(token: string): string {
|
|
48
|
+
return createHash('sha256').update(token).digest('base64url')
|
|
49
|
+
}
|