@baseworks/auth 0.2.5 → 1.0.2

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/src/cli.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Importable `login` / `logout` commands for any CLI that authenticates through
3
- * an auth-service (OIDC/PKCE device flow). Mirrors the `@baseworks/org/cli` and
4
- * `@baseworks/iam/cli` pattern — the commands live in auth's own package; the
5
- * app composes them:
2
+ * Importable `login` / `logout` / `auth status` commands for any CLI that
3
+ * authenticates through an auth-service (OIDC/PKCE device flow). Mirrors the
4
+ * `@baseworks/org/cli` and `@baseworks/iam/cli` pattern — the commands live in
5
+ * auth's own package; the app composes them:
6
6
  *
7
7
  * import { addAuthCommands } from '@baseworks/auth/cli'
8
8
  * addAuthCommands(program, { authBase, cliName: 'dtab', onToken, onLogout })
@@ -12,10 +12,15 @@
12
12
  * HS256) that org-service and iam-service verify with the shared realm secret —
13
13
  * so ONE login token works across the whole plane. Org context is not in the
14
14
  * token; the app resolves it via org-service (`use` / `ls`).
15
+ *
16
+ * Storage stays with the app: this module never touches the filesystem. Pass
17
+ * `onToken` / `onLogout` to persist, and `getToken` to enable `auth status`.
15
18
  */
16
19
  import { spawn } from 'node:child_process'
17
20
  import { Command } from 'commander'
18
21
  import { clr, kv, success, warn, fatal } from '@baseworks/cli/display'
22
+ import { fmtRelative } from '@baseworks/cli/fmt'
23
+ import { parseJwtPayload } from './jwt.js'
19
24
  import { startCliAuth, pollCliAuth } from './cli-auth.js'
20
25
 
21
26
  export interface AuthCliDeps {
@@ -28,6 +33,18 @@ export interface AuthCliDeps {
28
33
  onLogout?: () => void | Promise<void>
29
34
  /** Open the browser automatically (default true). */
30
35
  openBrowser?: boolean
36
+ /**
37
+ * Read the saved identity token. Providing it registers `<cli> auth status`,
38
+ * and lets `logout` warn when a token is still reachable afterwards — an
39
+ * environment variable that outranks the config file, typically.
40
+ */
41
+ getToken?: () => string | undefined
42
+ /** Where the token came from, for display: a config path, or an env var name. */
43
+ tokenOrigin?: () => string | undefined
44
+ /** Extra rows for `auth status`, e.g. the API base URL the app will call. */
45
+ statusRows?: () => [string, string][]
46
+ /** One-line hint printed after a successful login. */
47
+ nextSteps?: string
31
48
  }
32
49
 
33
50
  function resolveBase(b: AuthCliDeps['authBase']): string {
@@ -42,14 +59,60 @@ function openInBrowser(url: string): void {
42
59
  try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch { /* print fallback below */ }
43
60
  }
44
61
 
45
- /** Decode a JWT payload without verifying (display only). */
46
- function jwtSub(token: string): string | undefined {
47
- try {
48
- const seg = token.split('.')[1]
49
- if (!seg) return undefined
50
- const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
51
- return (JSON.parse(json) as { sub?: string }).sub
52
- } catch { return undefined }
62
+ interface IdentityClaims {
63
+ sub?: string
64
+ exp?: number
65
+ iss?: string
66
+ type?: string
67
+ }
68
+
69
+ /**
70
+ * Read the token's claims WITHOUT verifying the signature — display only.
71
+ * Verification is the server's job; a CLI holds no key and needs none.
72
+ */
73
+ function claimsOf(token: string): IdentityClaims {
74
+ return (parseJwtPayload(token) ?? {}) as IdentityClaims
75
+ }
76
+
77
+ function expiryLine(claims: IdentityClaims): string {
78
+ if (!claims.exp) return 'unknown'
79
+ const at = new Date(claims.exp * 1000).toISOString().replace('T', ' ').slice(0, 16)
80
+ return `${at} UTC (${fmtRelative(claims.exp)})`
81
+ }
82
+
83
+ /** `<cli> auth` and `<cli> auth status` — what the saved token says, no network. */
84
+ export function buildAuthCommand(deps: AuthCliDeps): Command {
85
+ const cli = deps.cliName ?? 'cli'
86
+
87
+ const status = (): void => {
88
+ const token = deps.getToken?.()
89
+ if (!token) fatal(`not signed in — run \`${cli} login\``)
90
+
91
+ const claims = claimsOf(token)
92
+ const rows: [string, string][] = [...(deps.statusRows?.() ?? [])]
93
+ rows.push(['user', claims.sub ?? 'unknown'])
94
+ rows.push(['token type', claims.type ?? 'unknown'])
95
+ // `iss` is absent from tokens minted before the realm started claiming one;
96
+ // an empty row would be noise, so show it only when it is there.
97
+ if (claims.iss) rows.push(['issuer', claims.iss])
98
+ rows.push(['expires', expiryLine(claims)])
99
+ const origin = deps.tokenOrigin?.()
100
+ if (origin) rows.push(['token from', origin])
101
+ kv(rows)
102
+
103
+ if (claims.exp && claims.exp * 1000 <= Date.now()) {
104
+ console.log('')
105
+ fatal(`the token has expired — run \`${cli} login\``)
106
+ }
107
+ console.log('')
108
+ }
109
+
110
+ const cmd = new Command('auth')
111
+ .description('Show the current sign-in (alias: auth status)')
112
+ .action(status)
113
+
114
+ cmd.addCommand(new Command('status').description('Show the current sign-in').action(status))
115
+ return cmd
53
116
  }
54
117
 
55
118
  export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
@@ -67,13 +130,23 @@ export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
67
130
  console.log(` ${clr.dim}If it does not open, visit:${clr.reset}\n ${clr.cyan}${start.url}${clr.reset}\n`)
68
131
  if (deps.openBrowser !== false && opts.open !== false) openInBrowser(start.url)
69
132
 
70
- const { token } = await pollCliAuth(base, start.state).catch((e: Error) => fatal(e.message))
133
+ // Without this the command sits silent for minutes and looks hung.
134
+ const minutes = Math.max(1, Math.round(start.expiresIn / 60))
135
+ console.log(` ${clr.dim}Waiting for approval — the link is valid for ${minutes}m…${clr.reset}`)
136
+
137
+ // Stop exactly when the server drops the state, not before: a shorter
138
+ // client timeout would report failure while the link still works.
139
+ const { token } = await pollCliAuth(base, start.state, { timeoutMs: start.expiresIn * 1000 })
140
+ .catch((e: Error) => fatal(e.message))
71
141
  await deps.onToken(token)
72
142
 
73
- const sub = jwtSub(token)
143
+ const claims = claimsOf(token)
74
144
  success('Logged in.')
75
- if (sub) kv([['user id', sub]])
76
- console.log(` ${clr.dim}Next: ${cli} use · ${cli} orgs${clr.reset}\n`)
145
+ const rows: [string, string][] = []
146
+ if (claims.sub) rows.push(['user', claims.sub])
147
+ if (claims.exp) rows.push(['expires', expiryLine(claims)])
148
+ if (rows.length) kv(rows)
149
+ console.log(` ${clr.dim}Next: ${deps.nextSteps ?? `${cli} use · ${cli} orgs`}${clr.reset}\n`)
77
150
  }),
78
151
  )
79
152
 
@@ -84,6 +157,20 @@ export function addAuthCommands(program: Command, deps: AuthCliDeps): void {
84
157
  if (deps.onLogout) await deps.onLogout()
85
158
  else warn('No logout handler configured.')
86
159
  success('Logged out.')
160
+
161
+ // A token in the environment outranks the config file, so clearing the
162
+ // file alone can leave the user still signed in without knowing it.
163
+ const leftover = deps.getToken?.()
164
+ if (leftover) {
165
+ const origin = deps.tokenOrigin?.()
166
+ warn(`A token is still in effect${origin ? ` (${origin})` : ''} and takes precedence.`)
167
+ }
168
+ console.log(
169
+ ` ${clr.dim}The token stays valid on the server until it expires; there is no server-side revocation.${clr.reset}\n`,
170
+ )
87
171
  }),
88
172
  )
173
+
174
+ // `auth status` only makes sense when the app can hand back what it stored.
175
+ if (deps.getToken) program.addCommand(buildAuthCommand(deps))
89
176
  }
package/src/hono.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { Context, Next } from 'hono'
2
- import { verifyHs256Jwt } from './jwt.js'
2
+ import { verifier, type IdentityClaims, type Verifier } from './identity.js'
3
3
 
4
4
  export interface ServiceAuthUser {
5
5
  userId: string
6
6
  orgId?: string
7
7
  role?: string
8
- tokenType: 'user' | 'service'
8
+ email?: string
9
+ tokenType: 'user' | 'service' | 'assistant'
9
10
  }
10
11
 
11
12
  declare module 'hono' {
@@ -15,26 +16,79 @@ declare module 'hono' {
15
16
  }
16
17
 
17
18
  /**
18
- * Hono middleware — verifies HS256 JWT from Authorization: Bearer header.
19
- * Reads JWT_SECRET from process.env.
20
- * Sets c.var.auth on success.
19
+ * Where the realm's public keys are, and what to expect in a token.
20
+ *
21
+ * Read from the environment so a service picks this up with no code of its own:
22
+ *
23
+ * AUTH_JWKS_URI e.g. https://auth.example.com/.well-known/jwks.json
24
+ * AUTH_ISSUER expected `iss`; unset means "do not check"
25
+ * AUTH_AUDIENCE expected `aud`; unset means "do not check"
26
+ * JWT_SECRET legacy HS256 secret, accepted alongside ES256 while set
27
+ *
28
+ * With only JWT_SECRET set, behaviour is exactly what it was before JWKS
29
+ * existed, so a service can be upgraded before the realm has keys.
30
+ */
31
+ function fromEnv(env: NodeJS.ProcessEnv): Verifier | { error: string } {
32
+ const jwksUri = env['AUTH_JWKS_URI']
33
+ const legacy = env['JWT_SECRET']
34
+ if (!jwksUri && !legacy) {
35
+ return { error: 'neither AUTH_JWKS_URI nor JWT_SECRET is configured' }
36
+ }
37
+ return verifier({
38
+ ...(jwksUri ? { jwksUri } : {}),
39
+ ...(env['AUTH_ISSUER'] ? { issuer: env['AUTH_ISSUER'] } : {}),
40
+ ...(env['AUTH_AUDIENCE'] ? { audience: env['AUTH_AUDIENCE'] } : {}),
41
+ ...(legacy ? { legacyHs256Secret: legacy } : {}),
42
+ })
43
+ }
44
+
45
+ // Built once: the verifier owns a JWKS cache, and rebuilding it per request
46
+ // would refetch the key set on every call.
47
+ let cachedVerifier: Verifier | { error: string } | undefined
48
+
49
+ /** Tests change the environment between cases. */
50
+ export function resetAuthVerifier(): void {
51
+ cachedVerifier = undefined
52
+ }
53
+
54
+ function claimsToUser(claims: IdentityClaims): ServiceAuthUser {
55
+ const type = claims.type
56
+ return {
57
+ userId: String(claims.sub ?? ''),
58
+ orgId: claims['org_id'] as string | undefined,
59
+ role: claims['role'] as string | undefined,
60
+ email: claims['email'] as string | undefined,
61
+ // Anything unrecognised is treated as a user token: `service` and
62
+ // `assistant` open doors a plain user token must not, so they are named
63
+ // explicitly and everything else falls to the least privileged reading.
64
+ tokenType: type === 'service' ? 'service' : type === 'assistant' ? 'assistant' : 'user',
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Hono middleware — verifies the realm's identity token from
70
+ * `Authorization: Bearer` and sets `c.var.auth`.
71
+ *
72
+ * ES256 verified against the realm's published JWKS, plus HS256 while a legacy
73
+ * secret is configured. The shared secret is a migration door: any service
74
+ * holding it can also MINT tokens, including one claiming to be another user,
75
+ * so it should be removed once the realm issues ES256 everywhere.
21
76
  */
22
77
  export async function requireAuth(c: Context, next: Next) {
23
78
  const header = c.req.header('Authorization')
24
79
  if (!header?.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401)
25
80
 
26
- const token = header.slice(7)
27
- const secret = process.env['JWT_SECRET']
28
- if (!secret) return c.json({ error: 'JWT_SECRET not configured' }, 500)
81
+ cachedVerifier ??= fromEnv(process.env)
82
+ if (typeof cachedVerifier !== 'function') {
83
+ // A misconfigured service must not look like a rejected caller: 500 tells
84
+ // the operator it is their problem, 401 would send the user in circles.
85
+ console.error(`[auth] ${cachedVerifier.error}`)
86
+ return c.json({ error: 'auth is not configured' }, 500)
87
+ }
29
88
 
30
- const claims = verifyHs256Jwt(token, secret)
89
+ const claims = await cachedVerifier(header.slice(7))
31
90
  if (!claims) return c.json({ error: 'Unauthorized' }, 401)
32
91
 
33
- c.set('auth', {
34
- userId: String(claims['sub'] ?? ''),
35
- orgId: claims['org_id'] as string | undefined,
36
- role: claims['role'] as string | undefined,
37
- tokenType: claims['type'] === 'service' ? 'service' : 'user',
38
- })
92
+ c.set('auth', claimsToUser(claims))
39
93
  return next()
40
94
  }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Realm identity tokens: minting, publication and verification.
3
+ *
4
+ * One realm has one issuer — its auth-service — and one signing key pair. Every
5
+ * other service in the realm (org, iam, billing, pay, and each product API)
6
+ * verifies with the PUBLIC key it fetches from `/.well-known/jwks.json`, and
7
+ * therefore cannot mint anything.
8
+ *
9
+ * That asymmetry is the whole point. With a shared HMAC secret, every service
10
+ * holding it can also *issue* tokens — including one that claims to be another
11
+ * user. This is not theoretical: a product API was found minting
12
+ * `{ sub: <some other user>, type: 'user' }` to write a membership on that
13
+ * user's behalf. Under ES256 that is impossible by construction: the private
14
+ * key never leaves auth-service.
15
+ *
16
+ * Claims: `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `type`. Org and role are
17
+ * deliberately absent — membership changes while a token lives, so a token that
18
+ * carries a role goes stale and lies. Consumers ask the org service instead.
19
+ *
20
+ * HS256 stays available during migration; see `verifier()` below.
21
+ */
22
+
23
+ import {
24
+ SignJWT,
25
+ jwtVerify,
26
+ exportJWK,
27
+ importPKCS8,
28
+ importSPKI,
29
+ calculateJwkThumbprint,
30
+ createLocalJWKSet,
31
+ createRemoteJWKSet,
32
+ type JSONWebKeySet,
33
+ type JWK,
34
+ } from 'jose'
35
+
36
+ export const IDENTITY_ALG = 'ES256'
37
+
38
+ /** `human` signs in; `service` is a service acting as itself; `assistant` is a narrowed token. */
39
+ export type IdentityType = 'human' | 'service' | 'assistant'
40
+
41
+ export interface IdentityClaims {
42
+ sub: string
43
+ type: IdentityType
44
+ iss?: string
45
+ aud?: string
46
+ exp?: number
47
+ iat?: number
48
+ jti?: string
49
+ /** Narrowed tokens carry the org they are scoped to; identity tokens do not. */
50
+ org_id?: string
51
+ scope?: string
52
+ [key: string]: unknown
53
+ }
54
+
55
+ export interface SigningKey {
56
+ privateKey: CryptoKey
57
+ /** Published in the JWKS and in every token header, so verifiers pick the right key. */
58
+ kid: string
59
+ publicJwk: JWK
60
+ }
61
+
62
+ // ─── Key material ────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Accept a PEM as-is, or base64 of a PEM.
66
+ *
67
+ * Environment variables and PEM disagree about newlines: a key pasted into a
68
+ * shell, a Kubernetes Secret or a CI variable loses them often enough that
69
+ * requiring raw PEM guarantees confusing failures. Base64 survives every one of
70
+ * those paths, so both forms are accepted.
71
+ */
72
+ function toPem(raw: string, label: 'PRIVATE' | 'PUBLIC'): string {
73
+ const value = raw.trim()
74
+ if (value.includes('-----BEGIN')) return value.replace(/\\n/g, '\n')
75
+ const decoded = Buffer.from(value, 'base64').toString('utf8')
76
+ if (decoded.includes('-----BEGIN')) return decoded.trim()
77
+ throw new Error(`not a ${label} key: expected PEM, or base64 of PEM`)
78
+ }
79
+
80
+ async function jwkOf(key: CryptoKey, kid?: string): Promise<JWK> {
81
+ const jwk = await exportJWK(key)
82
+ jwk.alg = IDENTITY_ALG
83
+ jwk.use = 'sig'
84
+ // A thumbprint (RFC 7638) is derived from the key itself, so the same key
85
+ // always gets the same id and two different keys can never collide. Deriving
86
+ // beats configuring: one less value to keep in step across services.
87
+ jwk.kid = kid ?? (await calculateJwkThumbprint(jwk))
88
+ return jwk
89
+ }
90
+
91
+ /** Load the signing key pair from a PKCS#8 private key. */
92
+ export async function loadSigningKey(privateKeyPem: string, kid?: string): Promise<SigningKey> {
93
+ const pem = toPem(privateKeyPem, 'PRIVATE')
94
+ const privateKey = await importPKCS8(pem, IDENTITY_ALG, { extractable: true })
95
+ const publicJwk = await jwkOf(privateKey, kid)
96
+ // The private half must never reach the JWKS; exportJWK on a private key
97
+ // returns `d`, and publishing it would hand the realm away.
98
+ delete publicJwk.d
99
+ return { privateKey, kid: publicJwk.kid as string, publicJwk }
100
+ }
101
+
102
+ /** Load a public key (SPKI PEM) for publication only — a retired key during rotation. */
103
+ export async function loadPublicKey(publicKeyPem: string, kid?: string): Promise<JWK> {
104
+ const key = await importSPKI(toPem(publicKeyPem, 'PUBLIC'), IDENTITY_ALG, { extractable: true })
105
+ return jwkOf(key, kid)
106
+ }
107
+
108
+ /**
109
+ * The document served at `/.well-known/jwks.json`.
110
+ *
111
+ * Rotation works by publishing both keys for longer than a token lives: add the
112
+ * new key, sign with it, keep the old one listed until every token signed by it
113
+ * has expired, then drop it.
114
+ */
115
+ export function jwks(signing: SigningKey, retired: JWK[] = []): JSONWebKeySet {
116
+ return { keys: [signing.publicJwk, ...retired] }
117
+ }
118
+
119
+ // ─── Minting ─────────────────────────────────────────────────────────────────
120
+
121
+ export interface MintOptions {
122
+ issuer: string
123
+ audience: string
124
+ expiresInSeconds: number
125
+ }
126
+
127
+ export async function mint(
128
+ key: SigningKey,
129
+ claims: IdentityClaims,
130
+ opts: MintOptions,
131
+ ): Promise<string> {
132
+ const { sub, type, ...rest } = claims
133
+ return new SignJWT({ type, ...rest })
134
+ .setProtectedHeader({ alg: IDENTITY_ALG, kid: key.kid })
135
+ .setSubject(sub)
136
+ .setIssuer(opts.issuer)
137
+ .setAudience(opts.audience)
138
+ .setIssuedAt()
139
+ // A unique id per token is what makes a future revocation list possible;
140
+ // without it a leaked token cannot be named, only waited out.
141
+ .setJti(crypto.randomUUID())
142
+ .setExpirationTime(`${opts.expiresInSeconds}s`)
143
+ .sign(key.privateKey)
144
+ }
145
+
146
+ // ─── Verification ────────────────────────────────────────────────────────────
147
+
148
+ export interface VerifierOptions {
149
+ /** Where to fetch the realm's public keys. jose caches and refetches on an unknown `kid`. */
150
+ jwksUri?: string
151
+ /** A fixed key set instead of a URL — for tests, or a service that ships with the realm. */
152
+ jwks?: JSONWebKeySet
153
+ issuer?: string
154
+ audience?: string
155
+ /**
156
+ * Accept HS256 tokens signed with this shared secret, in addition to ES256.
157
+ *
158
+ * Migration only. While it is set, any holder of the secret can still mint
159
+ * tokens for this realm — including tokens impersonating a user — so it must
160
+ * be removed once every issuer has moved to ES256.
161
+ */
162
+ legacyHs256Secret?: string
163
+ }
164
+
165
+ export interface Verifier {
166
+ (token: string): Promise<IdentityClaims | null>
167
+ }
168
+
169
+ /**
170
+ * Build a verifier for the realm.
171
+ *
172
+ * Returns `null` rather than throwing on every rejection: a caller answering an
173
+ * HTTP request wants one 401 path, not a taxonomy of crypto errors. Reasons
174
+ * worth acting on (an unreachable JWKS, say) surface through `onError`.
175
+ */
176
+ export function verifier(opts: VerifierOptions & { onError?: (err: unknown) => void }): Verifier {
177
+ if (!opts.jwksUri && !opts.jwks && !opts.legacyHs256Secret) {
178
+ throw new Error('verifier needs jwksUri, jwks, or legacyHs256Secret')
179
+ }
180
+
181
+ const keys = opts.jwks
182
+ ? createLocalJWKSet(opts.jwks)
183
+ : opts.jwksUri
184
+ ? createRemoteJWKSet(new URL(opts.jwksUri))
185
+ : undefined
186
+
187
+ const legacy = opts.legacyHs256Secret
188
+ ? new TextEncoder().encode(opts.legacyHs256Secret)
189
+ : undefined
190
+
191
+ const expectations = {
192
+ ...(opts.issuer ? { issuer: opts.issuer } : {}),
193
+ ...(opts.audience ? { audience: opts.audience } : {}),
194
+ }
195
+
196
+ return async (token: string): Promise<IdentityClaims | null> => {
197
+ if (keys) {
198
+ try {
199
+ const { payload } = await jwtVerify(token, keys, {
200
+ ...expectations,
201
+ algorithms: [IDENTITY_ALG],
202
+ })
203
+ return payload as unknown as IdentityClaims
204
+ } catch (err) {
205
+ // Fall through to the legacy secret when one is configured: during
206
+ // migration both kinds of token are in circulation at once.
207
+ if (!legacy) {
208
+ opts.onError?.(err)
209
+ return null
210
+ }
211
+ }
212
+ }
213
+
214
+ if (!legacy) return null
215
+ try {
216
+ // Legacy tokens predate iss/aud, so those are not required of them.
217
+ const { payload } = await jwtVerify(token, legacy, { algorithms: ['HS256'] })
218
+ return payload as unknown as IdentityClaims
219
+ } catch (err) {
220
+ opts.onError?.(err)
221
+ return null
222
+ }
223
+ }
224
+ }
package/src/index.ts CHANGED
@@ -21,20 +21,6 @@ export { hashToken, looksLikeJwt, stripBearer } from './token';
21
21
  export { signHs256Jwt, verifyHs256Jwt } from './jwt';
22
22
  export type { JwtClaims } from './jwt';
23
23
 
24
- // URL helpers (auth/account public URLs, login/logout URL builders)
25
- export {
26
- normalizeUrlLike,
27
- getAuthPublicUrl,
28
- getAccountPublicUrl,
29
- getAppBasePath,
30
- getPublicSiteUrl,
31
- buildPublicUrl,
32
- buildAuthLoginUrl,
33
- buildAuthLogoutUrl,
34
- buildAccountProfileUrl,
35
- } from './url-helpers';
36
-
37
- // Next.js session + edge exports are intentionally NOT re-exported here.
38
- // Use subpath imports: @baseworks/auth/session, @baseworks/auth/edge
39
- // This keeps the main index Worker-safe (no next/server dependency).
40
-
24
+ // Browser sessions are not here. Signing a person in, and keeping them signed
25
+ // in, is auth-service's job: this package is what a consumer shares, not a
26
+ // second place to run a login.
package/src/jwt.ts CHANGED
@@ -18,6 +18,7 @@ export interface JwtClaims {
18
18
  iat?: number
19
19
  org_id?: string
20
20
  role?: string
21
+ email?: string
21
22
  type?: string
22
23
  [key: string]: unknown
23
24
  }
@@ -1,69 +0,0 @@
1
- // src/url-helpers.ts
2
- function normalizeUrlLike(value) {
3
- if (value === "/") return "";
4
- return value.replace(/\/+$/, "");
5
- }
6
- function joinPublicUrl(base, path = "") {
7
- const normalizedBase = normalizeUrlLike(base);
8
- const normalizedPath = path.startsWith("/") ? path : `/${path}`;
9
- if (!normalizedBase) return normalizedPath;
10
- if (/^https?:\/\//.test(normalizedBase)) {
11
- return new URL(normalizedPath, `${normalizedBase}/`).toString();
12
- }
13
- return `${normalizedBase}${normalizedPath}`;
14
- }
15
- function getAuthPublicUrl() {
16
- return normalizeUrlLike(process.env.NEXT_PUBLIC_AUTH_URL ?? "/auth");
17
- }
18
- function getAccountPublicUrl() {
19
- return normalizeUrlLike(process.env.NEXT_PUBLIC_ACCOUNT_URL ?? "/account");
20
- }
21
- function getAppBasePath(defaultPath = "") {
22
- return normalizeUrlLike(
23
- process.env.NEXT_PUBLIC_APP_BASE_PATH ?? process.env.APP_BASE_PATH ?? defaultPath
24
- );
25
- }
26
- function getPublicSiteUrl() {
27
- return normalizeUrlLike(process.env.APP_PUBLIC_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "");
28
- }
29
- function buildPublicUrl(pathOrUrl, fallbackOrigin) {
30
- if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
31
- const origin = getPublicSiteUrl() || fallbackOrigin;
32
- if (!origin) return pathOrUrl;
33
- return new URL(pathOrUrl, `${normalizeUrlLike(origin)}/`).toString();
34
- }
35
- function buildAuthUrl(path, redirectTo, origin) {
36
- const target = joinPublicUrl(getAuthPublicUrl(), path);
37
- const targetIsAbsolute = /^https?:\/\//.test(target);
38
- const fallbackOrigin = "http://localhost";
39
- const url = new URL(target, origin ?? fallbackOrigin);
40
- url.searchParams.set("redirectTo", redirectTo);
41
- const href = url.toString();
42
- if (origin || targetIsAbsolute) return href;
43
- return href.replace(fallbackOrigin, "");
44
- }
45
- function buildAuthLoginUrl(redirectTo, origin) {
46
- return buildAuthUrl("/login", redirectTo, origin);
47
- }
48
- function buildAuthLogoutUrl(redirectTo, origin) {
49
- return buildAuthUrl(
50
- "/logout",
51
- process.env.NEXT_PUBLIC_SIGN_OUT_REDIRECT_URL ?? redirectTo,
52
- origin
53
- );
54
- }
55
- function buildAccountProfileUrl() {
56
- return joinPublicUrl(getAccountPublicUrl(), "/profile");
57
- }
58
-
59
- export {
60
- normalizeUrlLike,
61
- getAuthPublicUrl,
62
- getAccountPublicUrl,
63
- getAppBasePath,
64
- getPublicSiteUrl,
65
- buildPublicUrl,
66
- buildAuthLoginUrl,
67
- buildAuthLogoutUrl,
68
- buildAccountProfileUrl
69
- };