@wtfalch/auth 0.4.1 → 0.6.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/dist/cookies.js CHANGED
@@ -4,6 +4,13 @@ const SESSION = 'wtfalch_auth';
4
4
  const TRANSACTION = 'wtfalch_auth_tx';
5
5
  const LINK = 'wtfalch_auth_link';
6
6
  const TRANSACTION_TTL = 10 * 60;
7
+ /**
8
+ * A namespace session handle lives as long as a browser will keep a cookie.
9
+ * It grants nothing by itself: the server record decides, and slides its own
10
+ * expiry. Refreshing never sets it again, which is what stops a late response
11
+ * from putting back a cookie the person has since replaced.
12
+ */
13
+ const HANDLE_TTL = 400 * 24 * 60 * 60;
7
14
  export function sessionCookieName(options) {
8
15
  return options.secure ? `__Host-${SESSION}` : SESSION;
9
16
  }
@@ -25,6 +32,37 @@ export async function openSession(options, value) {
25
32
  export function clearSession(options) {
26
33
  return setCookie(sessionCookieName(options), '', 0, options.secure);
27
34
  }
35
+ /** The namespace session cookie: a random handle to a server record, nothing else. */
36
+ export async function sealHandle(options, sid) {
37
+ const value = await seal(options, 'handle', { sid }, HANDLE_TTL);
38
+ return setCookie(sessionCookieName(options), value, HANDLE_TTL, options.secure);
39
+ }
40
+ export async function openHandle(options, value) {
41
+ const payload = await open(options, 'handle', value);
42
+ return payload && typeof payload.sid === 'string' ? payload.sid : null;
43
+ }
44
+ export function sealRecord(options, payload) {
45
+ return seal(options, 'record', payload, HANDLE_TTL);
46
+ }
47
+ /** The tokens, if this payload was sealed for this record under this binding. */
48
+ export async function openRecord(options, key, value) {
49
+ const payload = await open(options, 'record', value);
50
+ return payload && payload.k === key && typeof payload.idt === 'string' ? payload : null;
51
+ }
52
+ /** The binding digest a namespace record is filed under. */
53
+ export async function contextDigest(options) {
54
+ if (!options.namespace)
55
+ throw new Error('@wtfalch/auth: no namespace binding');
56
+ return digest(options.namespace.context);
57
+ }
58
+ /** SHA-256, base64url. */
59
+ export async function digest(value) {
60
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
61
+ return btoa(String.fromCharCode(...new Uint8Array(bytes)))
62
+ .replaceAll('+', '-')
63
+ .replaceAll('/', '_')
64
+ .replace(/=+$/, '');
65
+ }
28
66
  export async function sealTransaction(options, payload) {
29
67
  const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
30
68
  return setCookie(transactionCookieName(options), value, TRANSACTION_TTL, options.secure);
@@ -61,11 +99,21 @@ export async function openLink(options, value) {
61
99
  export function clearLink(options) {
62
100
  return setCookie(linkCookieName(options), '', 0, options.secure);
63
101
  }
102
+ // A different audience also prevents old static consumers from accepting a
103
+ // namespace cookie. Hashing keeps the encrypted session below browser limits.
104
+ async function audience(options, kind) {
105
+ if (!options.namespace)
106
+ return kind;
107
+ return `${kind}:namespace:1:${await digest(options.namespace.context)}`;
108
+ }
64
109
  async function seal(options, kind, payload, ttl) {
65
- return new EncryptJWT(payload)
110
+ return new EncryptJWT({
111
+ ...payload,
112
+ ...(options.namespace ? { ns: options.namespace.namespaceId } : {}),
113
+ })
66
114
  .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
67
115
  .setIssuer(options.appUrl.origin)
68
- .setAudience(kind)
116
+ .setAudience(await audience(options, kind))
69
117
  .setIssuedAt()
70
118
  .setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
71
119
  .encrypt(options.cookieKey);
@@ -76,7 +124,7 @@ async function open(options, kind, value) {
76
124
  try {
77
125
  const { payload } = await jwtDecrypt(value, options.cookieKey, {
78
126
  issuer: options.appUrl.origin,
79
- audience: kind,
127
+ audience: await audience(options, kind),
80
128
  contentEncryptionAlgorithms: ['A256GCM'],
81
129
  keyManagementAlgorithms: ['dir'],
82
130
  });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
2
  export { type AuthOptions, ISSUER } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
+ export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
4
5
  export { BrokerError } from './broker.js';
5
6
  export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
6
7
  export { safeNextPath } from './redirect.js';
@@ -0,0 +1,4 @@
1
+ import type { BrowserAuth, BrowserAuthOptions } from './browser.js';
2
+ import { type NamespaceSelection } from './namespaces.js';
3
+ /** Isolated implementation: legacy storage and callback behavior stay unchanged. */
4
+ export declare function createNamespaceBrowserAuth(options: BrowserAuthOptions, selection: NamespaceSelection): BrowserAuth;
@@ -0,0 +1,232 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+ import { resolveNamespaceContext } from './namespaces.js';
3
+ import { safeNextPath } from './redirect.js';
4
+ const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
5
+ const ttl = 10 * 60_000;
6
+ const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
7
+ function base64url(bytes) {
8
+ return btoa(String.fromCharCode(...bytes))
9
+ .replaceAll('+', '-')
10
+ .replaceAll('/', '_')
11
+ .replace(/=+$/, '');
12
+ }
13
+ function read(storage, key) {
14
+ try {
15
+ return JSON.parse(storage.getItem(key) ?? 'null');
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** Isolated implementation: legacy storage and callback behavior stay unchanged. */
22
+ export function createNamespaceBrowserAuth(options, selection) {
23
+ const origin = options.origin ?? window.location.origin;
24
+ if (origin !== window.location.origin)
25
+ throw new Error('Namespace client must run on its registered origin');
26
+ const redirectUri = `${origin}${options.callbackPath ?? '/auth/callback'}`;
27
+ const binding = resolveNamespaceContext(selection, {
28
+ issuer: options.issuer,
29
+ clientId: options.clientId,
30
+ appOrigin: origin,
31
+ redirectUri,
32
+ });
33
+ const scope = options.scope ?? 'openid email profile offline_access';
34
+ const scopes = new Set(scope.split(/\s+/).filter(Boolean));
35
+ if (!scopes.has('openid') ||
36
+ [...scopes].some((s) => s.startsWith('urn:zitadel:iam:org:') &&
37
+ s !== `urn:zitadel:iam:org:id:${binding.organizationId}`))
38
+ throw new Error('Namespace client has conflicting organization scopes');
39
+ scopes.add(`urn:zitadel:iam:org:id:${binding.organizationId}`);
40
+ scopes.add('urn:zitadel:iam:user:resourceowner');
41
+ const prefix = `wtfalch.auth.namespace.v1:${binding.context}`;
42
+ const tokenKey = `${prefix}:token`;
43
+ const txKey = `${prefix}:transaction`;
44
+ const triedKey = `${prefix}:tried`;
45
+ // Shared across tabs and namespace clients in this service, never a token.
46
+ const serviceKey = `wtfalch.auth.active.v1:${JSON.stringify([origin, binding.serviceId, binding.deploymentId])}`;
47
+ const pendingKey = `${serviceKey}:pending`;
48
+ const pending = () => read(localStorage, pendingKey);
49
+ const ownsPending = (state) => {
50
+ const current = pending();
51
+ return current?.context === binding.context && current.state === state;
52
+ };
53
+ let keys;
54
+ const active = () => read(localStorage, serviceKey);
55
+ const stored = () => read(sessionStorage, tokenKey);
56
+ const accountChanged = () => {
57
+ const current = active();
58
+ const session = stored();
59
+ return (!!current &&
60
+ (current.context !== binding.context ||
61
+ (!!session && current.generation !== session.generation)));
62
+ };
63
+ const currentSession = () => {
64
+ const session = stored();
65
+ const current = active();
66
+ return session &&
67
+ current?.context === binding.context &&
68
+ current.generation === session.generation &&
69
+ typeof session.accessToken === 'string' &&
70
+ Number.isFinite(session.expiresAt) &&
71
+ session.expiresAt - 60_000 > Date.now()
72
+ ? session
73
+ : null;
74
+ };
75
+ const silentSignInAvailable = () => !accountChanged() && !currentSession() && !sessionStorage.getItem(triedKey);
76
+ const authorize = async (prompt, returnTo) => {
77
+ const transaction = {
78
+ context: binding.context,
79
+ state: random(),
80
+ nonce: random(),
81
+ verifier: random(),
82
+ returnTo: safeNextPath(returnTo, '/'),
83
+ started: Date.now(),
84
+ };
85
+ // Storage is required in namespace mode. If unavailable, fail before leaving.
86
+ sessionStorage.setItem(txKey, JSON.stringify(transaction));
87
+ localStorage.setItem(pendingKey, JSON.stringify({ context: binding.context, state: transaction.state }));
88
+ const url = new URL(`${binding.issuer}/oauth/v2/authorize`);
89
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(transaction.verifier));
90
+ url.search = new URLSearchParams({
91
+ client_id: binding.clientId,
92
+ redirect_uri: redirectUri,
93
+ response_type: 'code',
94
+ scope: [...scopes].join(' '),
95
+ state: transaction.state,
96
+ nonce: transaction.nonce,
97
+ code_challenge: base64url(new Uint8Array(digest)),
98
+ code_challenge_method: 'S256',
99
+ ...(prompt ? { prompt } : {}),
100
+ }).toString();
101
+ if (!ownsPending(transaction.state))
102
+ throw new Error('Another sign-in replaced this attempt');
103
+ window.location.assign(url.href);
104
+ };
105
+ const completeSignIn = async (search = window.location.search) => {
106
+ const reject = () => ({
107
+ kind: 'error',
108
+ error: 'This sign-in no longer matches the active namespace; start again',
109
+ });
110
+ const params = new URLSearchParams(search);
111
+ const tx = read(sessionStorage, txKey);
112
+ if (!tx ||
113
+ tx.context !== binding.context ||
114
+ typeof tx.state !== 'string' ||
115
+ typeof tx.nonce !== 'string' ||
116
+ typeof tx.verifier !== 'string' ||
117
+ typeof tx.returnTo !== 'string' ||
118
+ !Number.isFinite(tx.started) ||
119
+ tx.started > Date.now() ||
120
+ Date.now() - tx.started > ttl ||
121
+ params.getAll('state').length !== 1 ||
122
+ params.get('state') !== tx.state ||
123
+ !ownsPending(tx.state) ||
124
+ (params.has('iss') && params.get('iss') !== binding.issuer))
125
+ return reject();
126
+ sessionStorage.removeItem(txKey);
127
+ const error = params.get('error');
128
+ if (error) {
129
+ localStorage.removeItem(pendingKey);
130
+ return ['login_required', 'interaction_required', 'consent_required'].includes(error)
131
+ ? { kind: 'silent-refused' }
132
+ : { kind: 'error', error: 'The issuer refused this sign-in' };
133
+ }
134
+ if (params.getAll('code').length !== 1 || !params.get('code'))
135
+ return reject();
136
+ try {
137
+ const response = await fetch(`${binding.issuer}/oauth/v2/token`, {
138
+ method: 'POST',
139
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
140
+ body: new URLSearchParams({
141
+ grant_type: 'authorization_code',
142
+ client_id: binding.clientId,
143
+ redirect_uri: redirectUri,
144
+ code: params.get('code'),
145
+ code_verifier: tx.verifier,
146
+ }),
147
+ signal: AbortSignal.timeout(15_000),
148
+ });
149
+ if (!response.ok)
150
+ return reject();
151
+ const token = await response.json();
152
+ if (typeof token.access_token !== 'string' ||
153
+ !token.access_token ||
154
+ typeof token.id_token !== 'string' ||
155
+ !Number.isFinite(token.expires_in) ||
156
+ token.expires_in <= 0)
157
+ return reject();
158
+ if (!keys) {
159
+ const discoveryResponse = await fetch(`${binding.issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(15_000) });
160
+ if (!discoveryResponse.ok)
161
+ return reject();
162
+ const discovery = await discoveryResponse.json();
163
+ if (discovery.issuer !== binding.issuer || typeof discovery.jwks_uri !== 'string')
164
+ return reject();
165
+ const jwks = new URL(discovery.jwks_uri);
166
+ if (jwks.origin !== binding.issuer || jwks.username || jwks.password || jwks.hash)
167
+ return reject();
168
+ keys = createRemoteJWKSet(jwks);
169
+ }
170
+ const { payload } = await jwtVerify(token.id_token, keys, {
171
+ issuer: binding.issuer,
172
+ audience: binding.clientId,
173
+ algorithms: ['RS256'],
174
+ requiredClaims: ['exp', 'iat', 'sub'],
175
+ clockTolerance: 60,
176
+ });
177
+ if (payload.nonce !== tx.nonce ||
178
+ payload[OWNER] !== binding.organizationId ||
179
+ (payload.azp !== undefined && payload.azp !== binding.clientId) ||
180
+ (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== binding.clientId))
181
+ return reject();
182
+ // A different tab may have switched or signed out during the network calls.
183
+ if (!ownsPending(tx.state))
184
+ return reject();
185
+ const generation = random();
186
+ const session = {
187
+ accessToken: token.access_token,
188
+ expiresAt: Date.now() + token.expires_in * 1000,
189
+ namespaceId: binding.namespaceId,
190
+ bindingId: binding.id,
191
+ generation,
192
+ };
193
+ localStorage.setItem(serviceKey, JSON.stringify({ context: binding.context, generation }));
194
+ localStorage.removeItem(pendingKey);
195
+ sessionStorage.setItem(tokenKey, JSON.stringify(session));
196
+ sessionStorage.removeItem(triedKey);
197
+ return { kind: 'signed-in', session, returnTo: safeNextPath(tx.returnTo, '/') };
198
+ }
199
+ catch {
200
+ return reject();
201
+ }
202
+ };
203
+ return {
204
+ accountChanged,
205
+ currentSession,
206
+ silentSignInAvailable,
207
+ completeSignIn,
208
+ async trySilentSignIn(returnTo = window.location.pathname) {
209
+ if (!silentSignInAvailable())
210
+ return false;
211
+ sessionStorage.setItem(triedKey, '1');
212
+ await authorize('none', returnTo);
213
+ return true;
214
+ },
215
+ async signIn(returnTo = window.location.pathname) {
216
+ sessionStorage.removeItem(triedKey);
217
+ await authorize(undefined, returnTo);
218
+ },
219
+ forget() {
220
+ sessionStorage.removeItem(tokenKey);
221
+ sessionStorage.removeItem(txKey);
222
+ sessionStorage.setItem(triedKey, '1');
223
+ // Cancel this namespace's pending exchange even when another account is active.
224
+ if (pending()?.context === binding.context)
225
+ localStorage.removeItem(pendingKey);
226
+ // A stale X tab must not sign Y out.
227
+ if (!active() || active()?.context === binding.context) {
228
+ localStorage.setItem(serviceKey, JSON.stringify({ context: binding.context, generation: random() }));
229
+ }
230
+ },
231
+ };
232
+ }
@@ -0,0 +1,12 @@
1
+ import { type NamespaceRegistry, type NamespaceSelection } from './namespaces.js';
2
+ /** Resolve a shared callback from its authenticated transaction, never a URL
3
+ * namespace hint. The second open checks the entire current registry binding. */
4
+ export declare function resolveNamespaceCallback(input: {
5
+ registry: NamespaceRegistry;
6
+ serviceId: string;
7
+ deploymentId: string;
8
+ appOrigin: string;
9
+ cookieSecret: string;
10
+ cookieHeader: string | null;
11
+ basePath?: string;
12
+ }): Promise<NamespaceSelection | null>;
@@ -0,0 +1,64 @@
1
+ import { jwtDecrypt } from 'jose';
2
+ import { resolveOptions } from './config.js';
3
+ import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
4
+ import { parseNamespaceRegistry, } from './namespaces.js';
5
+ /** Resolve a shared callback from its authenticated transaction, never a URL
6
+ * namespace hint. The second open checks the entire current registry binding. */
7
+ export async function resolveNamespaceCallback(input) {
8
+ try {
9
+ const registry = parseNamespaceRegistry(input.registry);
10
+ const callback = new URL(`${input.basePath ?? '/auth'}/callback`, input.appOrigin).href;
11
+ const candidates = registry.namespaces.filter((n) => n.status === 'active' &&
12
+ n.services.some((s) => s.serviceId === input.serviceId &&
13
+ s.deploymentId === input.deploymentId &&
14
+ s.appOrigin === input.appOrigin &&
15
+ s.redirectUris.includes(callback)));
16
+ const optionsFor = (namespace) => {
17
+ const service = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
18
+ if (!service)
19
+ throw new Error('Missing service');
20
+ const selection = {
21
+ registry,
22
+ namespaceId: namespace.id,
23
+ serviceId: input.serviceId,
24
+ deploymentId: input.deploymentId,
25
+ };
26
+ return resolveOptions({
27
+ namespace: selection,
28
+ issuer: registry.issuer,
29
+ appUrl: input.appOrigin,
30
+ clientId: service.clientId,
31
+ organizationId: namespace.organizationId,
32
+ cookieSecret: input.cookieSecret,
33
+ basePath: input.basePath,
34
+ afterLogout: service.postLogoutRedirectUris[0],
35
+ });
36
+ };
37
+ if (!candidates.length)
38
+ return null;
39
+ const first = optionsFor(candidates[0]);
40
+ const value = cookieFrom(input.cookieHeader, transactionCookieName(first));
41
+ if (!value)
42
+ return null;
43
+ // No identity is returned based on this hint alone. It only chooses which
44
+ // current binding's audience to validate next, so decryption is bounded.
45
+ const { payload } = await jwtDecrypt(value, first.cookieKey, {
46
+ issuer: input.appOrigin,
47
+ requiredClaims: ['exp', 'iat', 'aud'],
48
+ contentEncryptionAlgorithms: ['A256GCM'],
49
+ keyManagementAlgorithms: ['dir'],
50
+ });
51
+ const namespace = candidates.find((n) => n.id === payload.ns);
52
+ if (!namespace || !(await openTransaction(optionsFor(namespace), value)))
53
+ return null;
54
+ return {
55
+ registry,
56
+ namespaceId: namespace.id,
57
+ serviceId: input.serviceId,
58
+ deploymentId: input.deploymentId,
59
+ };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ import type { JWTPayload } from 'jose';
2
+ import { type Broker } from './broker.js';
3
+ import type { ResolvedOptions } from './config.js';
4
+ import { type SetCookie } from './cookies.js';
5
+ import { type Oidc, type Tokens } from './oidc.js';
6
+ /** How long a binding status answer is trusted. The propagation bound for a suspension. */
7
+ export declare const STATUS_FRESH_MS = 60000;
8
+ /** How long the last good answer carries on while the service cannot be asked. */
9
+ export declare const STATUS_STALE_IF_ERROR_MS: number;
10
+ export type SessionRead = {
11
+ kind: 'none';
12
+ }
13
+ /** A cookie for another binding, revision or format. */
14
+ | {
15
+ kind: 'changed';
16
+ }
17
+ /** The binding is suspended, or its status cannot be confirmed. */
18
+ | {
19
+ kind: 'unavailable';
20
+ } | {
21
+ kind: 'user';
22
+ claims: JWTPayload;
23
+ };
24
+ /**
25
+ * Server-side sessions for namespace mode. The cookie holds a random handle;
26
+ * the record holds the tokens. Refreshes are serialised across replicas by a
27
+ * lease on the record, so the issuer's refresh-token rotation cannot log a
28
+ * person out when two requests arrive together, and no refresh ever sets a
29
+ * cookie.
30
+ */
31
+ export declare function namespaceSessions(deps: {
32
+ options: () => ResolvedOptions;
33
+ oidc: () => Oidc;
34
+ broker: () => Broker;
35
+ }): {
36
+ bindingActive: () => Promise<boolean>;
37
+ create: (tokens: Tokens) => Promise<SetCookie>;
38
+ end: (value: string | undefined) => Promise<"ended" | "changed" | "none">;
39
+ read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
40
+ revokeSubject: (subject: string) => Promise<number>;
41
+ keyOf: (value: string | undefined) => Promise<string | null>;
42
+ };