@crombolo/auth 0.0.0-stage → 0.10.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/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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.10.0",
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.10.0",
32
+ "@crombolo/data": "0.10.0"
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,8 @@
1
+ export * from './admin.ts'
2
+ export * from './cookies.ts'
3
+ export * from './member-session.ts'
4
+ export * from './password.ts'
5
+ export * from './pkce.ts'
6
+ export * from './routes.ts'
7
+ export * from './service.ts'
8
+ export * from './store.ts'
@@ -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
+ }
@@ -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
+ }