@planetlogin/core 0.1.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/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @planetlogin/core
2
+
3
+ The framework-agnostic **auth core** of PlanetLogin: the flows (password, magic link,
4
+ OAuth/OIDC, passkeys, TOTP), JWT/JWKS, all-terrain password verification, the
5
+ downstream contract client, and the pluggable session store.
6
+
7
+ No HTTP, no framework. Each **flavor** adds only its runtime's HTTP binding and calls
8
+ into this package — so the auth logic lives once and every flavor stays in lockstep
9
+ with the [SPEC](../planetpass/SPEC.md). It also owns the **test suite (31 tests, typecheck clean)** — the logic is validated
10
+ here, not in any flavor. Verified: the vanilla flavor consumes this and passes the
11
+ conformance suite 9/9; the SvelteKit flavor builds green over it.
12
+
13
+ ```ts
14
+ import { passwordLogin, signSession, verifyPassword, downstreamFromEnv } from '@planetlogin/core';
15
+ ```
16
+
17
+ Pure dependencies only (jose, hash-wasm, bcryptjs, @simplewebauthn/server, otpauth) —
18
+ no crypto is hand-rolled. Runs under tsx today; ships as compiled JS for publishing.
19
+
20
+ ## Build
21
+
22
+ ```bash
23
+ npm run build # tsup → dist/index.js (ESM) + dist/index.d.ts
24
+ npm test # 31 tests · npm run typecheck
25
+ ```
26
+
27
+ Flavors consume the built `dist/` (the `exports`/`types`/`main` all point there), so
28
+ they never transpile core source. `prepublishOnly` rebuilds before npm publish.
@@ -0,0 +1,493 @@
1
+ import * as jose from 'jose';
2
+ import { JWK } from 'jose';
3
+ import * as _simplewebauthn_server from '@simplewebauthn/server';
4
+
5
+ interface Locale {
6
+ language?: string;
7
+ timezone?: string;
8
+ country?: string;
9
+ }
10
+ interface SessionClaims {
11
+ sub: string;
12
+ email?: string;
13
+ name?: string;
14
+ locale?: Locale;
15
+ }
16
+ /** Public JWK Set served at GET /auth/.well-known/jwks.json. */
17
+ declare function jwks(): Promise<{
18
+ keys: JWK[];
19
+ }>;
20
+ declare function signSession(claims: SessionClaims, opts?: {
21
+ issuer?: string;
22
+ audience?: string;
23
+ ttlSeconds?: number;
24
+ }): Promise<string>;
25
+ /** Verify a session token against our own JWKS (what a downstream service does). */
26
+ declare function verifySession(token: string): Promise<jose.JWTPayload>;
27
+ declare function signMagicToken(identifier: string, ttlSeconds?: number): Promise<string>;
28
+ /** Returns {identifier, jti} if the token is a valid, unexpired magic token, else null. */
29
+ declare function verifyMagicToken(token: string): Promise<{
30
+ identifier: string;
31
+ jti: string;
32
+ } | null>;
33
+
34
+ interface DownstreamUser {
35
+ id: string;
36
+ email?: string;
37
+ name?: string;
38
+ passwordHash?: string;
39
+ locale?: Locale;
40
+ totpEnabled?: boolean;
41
+ }
42
+ declare class Downstream {
43
+ private baseUrl;
44
+ private secret;
45
+ private timeoutMs;
46
+ private fetchImpl;
47
+ constructor(baseUrl: string, secret: string, timeoutMs?: number, fetchImpl?: typeof fetch);
48
+ private call;
49
+ findUser(identifier: string): Promise<DownstreamUser | null>;
50
+ upsertUser(data: {
51
+ provider: string;
52
+ providerUserId?: string;
53
+ email?: string;
54
+ name?: string;
55
+ profile?: unknown;
56
+ }): Promise<DownstreamUser | null>;
57
+ /** Ask the integrator to deliver a magic link (it sends the email/SMS). */
58
+ deliverMagic(data: {
59
+ identifier: string;
60
+ link: string;
61
+ locale?: Locale;
62
+ }): Promise<unknown>;
63
+ /** Passkeys (spec §4): the integrator stores WebAuthn credentials. */
64
+ passkeysFind(query: {
65
+ userId?: string;
66
+ credentialId?: string;
67
+ }): Promise<{
68
+ userId: string;
69
+ credentials: any[];
70
+ } | null>;
71
+ passkeysSave(data: {
72
+ userId: string;
73
+ credential: any;
74
+ }): Promise<unknown>;
75
+ /** TOTP (2FA): the integrator stores the per-user secret + enabled flag. */
76
+ totpGet(query: {
77
+ userId: string;
78
+ }): Promise<{
79
+ secret: string;
80
+ enabled: boolean;
81
+ } | null>;
82
+ totpSave(data: {
83
+ userId: string;
84
+ secret: string;
85
+ enabled: boolean;
86
+ }): Promise<unknown>;
87
+ }
88
+
89
+ interface PlanetLoginConfig {
90
+ spec: 1;
91
+ brand: {
92
+ name: string;
93
+ logoUrl?: string;
94
+ accent?: string;
95
+ background?: string;
96
+ };
97
+ providers: {
98
+ password?: {
99
+ enabled?: boolean;
100
+ allowRegister?: boolean;
101
+ };
102
+ oauth?: Array<{
103
+ id: string;
104
+ label?: string;
105
+ clientIdEnv?: string;
106
+ }>;
107
+ magicLink?: {
108
+ enabled?: boolean;
109
+ ttlSeconds?: number;
110
+ };
111
+ passkeys?: {
112
+ enabled?: boolean;
113
+ };
114
+ totp?: {
115
+ enabled?: boolean;
116
+ };
117
+ saml?: {
118
+ enabled?: boolean;
119
+ idpMetadataUrl?: string;
120
+ };
121
+ };
122
+ copy?: Record<string, Record<string, string>>;
123
+ layout?: {
124
+ globePosition?: 'left' | 'right' | 'full';
125
+ showSearch?: boolean;
126
+ autoSpin?: boolean;
127
+ };
128
+ token?: {
129
+ issuer?: string;
130
+ audience?: string;
131
+ ttlSeconds?: number;
132
+ algorithm?: 'EdDSA' | 'RS256' | 'ES256' | 'HS256';
133
+ };
134
+ session?: {
135
+ store?: 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
136
+ };
137
+ }
138
+ declare function loadConfig(): PlanetLoginConfig;
139
+ /** The public subset served at GET /auth/config (no secrets). */
140
+ declare function publicConfig(c?: PlanetLoginConfig): {
141
+ spec: 1;
142
+ brand: {
143
+ name: string;
144
+ logoUrl?: string;
145
+ accent?: string;
146
+ background?: string;
147
+ };
148
+ providers: {
149
+ password?: {
150
+ enabled?: boolean;
151
+ allowRegister?: boolean;
152
+ };
153
+ oauth?: Array<{
154
+ id: string;
155
+ label?: string;
156
+ clientIdEnv?: string;
157
+ }>;
158
+ magicLink?: {
159
+ enabled?: boolean;
160
+ ttlSeconds?: number;
161
+ };
162
+ passkeys?: {
163
+ enabled?: boolean;
164
+ };
165
+ totp?: {
166
+ enabled?: boolean;
167
+ };
168
+ saml?: {
169
+ enabled?: boolean;
170
+ idpMetadataUrl?: string;
171
+ };
172
+ };
173
+ copy: Record<string, Record<string, string>>;
174
+ layout: {
175
+ globePosition?: "left" | "right" | "full";
176
+ showSearch?: boolean;
177
+ autoSpin?: boolean;
178
+ };
179
+ };
180
+ declare function downstreamFromEnv(): Downstream;
181
+
182
+ declare function hashPassword(password: string): Promise<string>;
183
+ /**
184
+ * Verify a plaintext password against a stored hash of ANY supported format.
185
+ * Detection is by the encoded-hash prefix. Never throws → returns false on any
186
+ * parse/verify failure or unknown/unsafe format (fail closed).
187
+ */
188
+ declare function verifyPassword(password: string, stored: string): Promise<boolean>;
189
+
190
+ interface SessionStore {
191
+ get(key: string): Promise<string | null>;
192
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
193
+ delete(key: string): Promise<void>;
194
+ /** Atomically claim a key once: true if newly claimed, false if already present. */
195
+ claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
196
+ }
197
+ /** No store: stateless. claimOnce always allows (single-use not enforced → TTL only). */
198
+ declare class NoneStore implements SessionStore {
199
+ get(): Promise<null>;
200
+ set(): Promise<void>;
201
+ delete(): Promise<void>;
202
+ claimOnce(): Promise<boolean>;
203
+ }
204
+ declare class MemoryStore implements SessionStore {
205
+ private m;
206
+ private alive;
207
+ get(key: string): Promise<string | null>;
208
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
209
+ delete(key: string): Promise<void>;
210
+ claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
211
+ }
212
+ type StoreKind = 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
213
+ /** Build the store from config. Only none+memory ship in this flavor; the rest
214
+ * are declared in the spec and added per deployment need. */
215
+ declare function getStore(kind?: StoreKind): SessionStore;
216
+ declare const _stores: {
217
+ NoneStore: typeof NoneStore;
218
+ MemoryStore: typeof MemoryStore;
219
+ };
220
+
221
+ interface ProviderConfig {
222
+ authorizeUrl: string;
223
+ tokenUrl: string;
224
+ userinfoUrl: string;
225
+ scopes: string[];
226
+ mapProfile: (raw: any) => {
227
+ providerUserId: string;
228
+ email?: string;
229
+ name?: string;
230
+ };
231
+ }
232
+ declare function pkcePair(): {
233
+ verifier: string;
234
+ challenge: string;
235
+ };
236
+ /** Resolve a provider config. Endpoints are overridable per provider via env
237
+ * (PLANETLOGIN_OAUTH_<ID>_{AUTHORIZE,TOKEN,USERINFO}_URL). Unknown ids become a
238
+ * generic OIDC-shaped provider built entirely from those env vars. */
239
+ declare function getProvider(id: string): ProviderConfig;
240
+ declare function buildAuthUrl(p: ProviderConfig, a: {
241
+ clientId: string;
242
+ redirectUri: string;
243
+ state: string;
244
+ challenge: string;
245
+ }): string;
246
+ /** /start helper: PKCE pair + state + the authorization URL. */
247
+ declare function oauthStart(p: ProviderConfig, a: {
248
+ clientId: string;
249
+ redirectUri: string;
250
+ }): {
251
+ url: string;
252
+ state: string;
253
+ codeVerifier: string;
254
+ };
255
+ declare function exchangeCode(p: ProviderConfig, a: {
256
+ clientId: string;
257
+ clientSecret: string;
258
+ code: string;
259
+ codeVerifier: string;
260
+ redirectUri: string;
261
+ }): Promise<string>;
262
+ declare function fetchProfile(p: ProviderConfig, accessToken: string): Promise<{
263
+ providerUserId: string;
264
+ email?: string;
265
+ name?: string;
266
+ }>;
267
+
268
+ interface OAuthStateData {
269
+ provider: string;
270
+ state: string;
271
+ codeVerifier: string;
272
+ redirectTo: string;
273
+ }
274
+ /** Generic short-lived encrypted (JWE A256GCM) cookie payload — used to keep
275
+ * OAuth state and WebAuthn challenges across a redirect/ceremony, statelessly. */
276
+ declare function sealEnc(data: unknown, ttlSeconds?: number): Promise<string>;
277
+ declare function openEnc<T = any>(token: string): Promise<T | null>;
278
+ declare const sealOAuthState: (d: OAuthStateData, ttl?: number) => Promise<string>;
279
+ declare const openOAuthState: (t: string) => Promise<OAuthStateData | null>;
280
+
281
+ interface StoredCredential {
282
+ id: string;
283
+ publicKey: string;
284
+ counter: number;
285
+ transports?: string[];
286
+ }
287
+ declare function registrationOptions(a: {
288
+ rpID: string;
289
+ rpName: string;
290
+ userId: string;
291
+ userName: string;
292
+ existing?: StoredCredential[];
293
+ }): Promise<_simplewebauthn_server.PublicKeyCredentialCreationOptionsJSON>;
294
+ declare function authenticationOptions(a: {
295
+ rpID: string;
296
+ allow?: StoredCredential[];
297
+ }): Promise<_simplewebauthn_server.PublicKeyCredentialRequestOptionsJSON>;
298
+ declare function verifyRegistration(a: {
299
+ response: any;
300
+ expectedChallenge: string;
301
+ origin: string;
302
+ rpID: string;
303
+ }): Promise<{
304
+ verified: boolean;
305
+ credential?: StoredCredential;
306
+ }>;
307
+ declare function verifyAuthentication(a: {
308
+ response: any;
309
+ expectedChallenge: string;
310
+ origin: string;
311
+ rpID: string;
312
+ credential: StoredCredential;
313
+ }): Promise<{
314
+ verified: boolean;
315
+ newCounter?: number;
316
+ }>;
317
+
318
+ declare function newTotpSecret(): string;
319
+ declare function totpKeyUri(secretB32: string, label: string, issuer?: string): string;
320
+ /** Verify a 6-digit code (±1 step window for clock skew). Never throws. */
321
+ declare function verifyTotp(secretB32: string, code: string): boolean;
322
+
323
+ interface PasswordLoginDeps {
324
+ downstream: Downstream;
325
+ verifyPassword: (password: string, hash: string) => Promise<boolean>;
326
+ signSession: (claims: SessionClaims) => Promise<string>;
327
+ }
328
+ interface PasswordLoginInput {
329
+ identifier: string;
330
+ password: string;
331
+ locale?: Locale;
332
+ }
333
+ type PasswordLoginResult = {
334
+ ok: true;
335
+ token: string;
336
+ user: {
337
+ id: string;
338
+ email?: string;
339
+ name?: string;
340
+ };
341
+ } | {
342
+ ok: 'mfa';
343
+ userId: string;
344
+ } | {
345
+ ok: false;
346
+ code: 'invalid_credentials' | 'downstream_unavailable';
347
+ };
348
+ declare function passwordLogin(deps: PasswordLoginDeps, input: PasswordLoginInput): Promise<PasswordLoginResult>;
349
+
350
+ interface MagicRequestDeps {
351
+ downstream: Downstream;
352
+ signMagicToken: (identifier: string) => Promise<string>;
353
+ }
354
+ interface MagicVerifyDeps {
355
+ downstream: Downstream;
356
+ verifyMagicToken: (token: string) => Promise<{
357
+ identifier: string;
358
+ jti: string;
359
+ } | null>;
360
+ signSession: (claims: SessionClaims) => Promise<string>;
361
+ store: SessionStore;
362
+ }
363
+ /** Always resolves `{ accepted: true }` (the route returns 202) — no enumeration. */
364
+ declare function requestMagicLink(deps: MagicRequestDeps, input: {
365
+ identifier: string;
366
+ baseUrl: string;
367
+ locale?: Locale;
368
+ }): Promise<{
369
+ accepted: true;
370
+ }>;
371
+ type MagicVerifyResult = {
372
+ ok: true;
373
+ token: string;
374
+ user: {
375
+ id: string;
376
+ email?: string;
377
+ name?: string;
378
+ };
379
+ } | {
380
+ ok: false;
381
+ code: 'invalid_token';
382
+ };
383
+ declare function verifyMagicLink(deps: MagicVerifyDeps, input: {
384
+ token: string;
385
+ locale?: Locale;
386
+ }): Promise<MagicVerifyResult>;
387
+
388
+ interface OAuthLoginDeps {
389
+ downstream: Downstream;
390
+ signSession: (claims: SessionClaims) => Promise<string>;
391
+ }
392
+ interface OAuthLoginInput {
393
+ provider: string;
394
+ providerCfg: ProviderConfig;
395
+ code: string;
396
+ codeVerifier: string;
397
+ clientId: string;
398
+ clientSecret: string;
399
+ redirectUri: string;
400
+ locale?: Locale;
401
+ }
402
+ type OAuthLoginResult = {
403
+ ok: true;
404
+ token: string;
405
+ user: {
406
+ id: string;
407
+ email?: string;
408
+ name?: string;
409
+ };
410
+ } | {
411
+ ok: false;
412
+ code: 'provider_error';
413
+ };
414
+ declare function oauthCallback(deps: OAuthLoginDeps, input: OAuthLoginInput): Promise<OAuthLoginResult>;
415
+
416
+ interface RegisterVerifyDeps {
417
+ downstream: Downstream;
418
+ verifyRegistration: (a: {
419
+ response: any;
420
+ expectedChallenge: string;
421
+ origin: string;
422
+ rpID: string;
423
+ }) => Promise<{
424
+ verified: boolean;
425
+ credential?: StoredCredential;
426
+ }>;
427
+ }
428
+ interface AuthVerifyDeps {
429
+ downstream: Downstream;
430
+ verifyAuthentication: (a: {
431
+ response: any;
432
+ expectedChallenge: string;
433
+ origin: string;
434
+ rpID: string;
435
+ credential: StoredCredential;
436
+ }) => Promise<{
437
+ verified: boolean;
438
+ newCounter?: number;
439
+ }>;
440
+ signSession: (claims: SessionClaims) => Promise<string>;
441
+ }
442
+ type RegisterResult = {
443
+ ok: true;
444
+ } | {
445
+ ok: false;
446
+ code: 'invalid_credentials';
447
+ };
448
+ type AuthResult = {
449
+ ok: true;
450
+ token: string;
451
+ user: {
452
+ id: string;
453
+ };
454
+ } | {
455
+ ok: false;
456
+ code: 'invalid_credentials';
457
+ };
458
+ declare function passkeyRegisterVerify(deps: RegisterVerifyDeps, input: {
459
+ response: any;
460
+ expectedChallenge: string;
461
+ userId: string;
462
+ origin: string;
463
+ rpID: string;
464
+ }): Promise<RegisterResult>;
465
+ declare function passkeyAuthVerify(deps: AuthVerifyDeps, input: {
466
+ response: any;
467
+ expectedChallenge: string;
468
+ origin: string;
469
+ rpID: string;
470
+ locale?: Locale;
471
+ }): Promise<AuthResult>;
472
+
473
+ interface TotpDeps {
474
+ downstream: Downstream;
475
+ }
476
+ declare function totpEnroll(deps: TotpDeps, input: {
477
+ userId: string;
478
+ label: string;
479
+ issuer?: string;
480
+ }): Promise<{
481
+ secret: string;
482
+ uri: string;
483
+ }>;
484
+ /** Verify a code. On first success for a not-yet-enabled secret, enable it
485
+ * (confirms enrollment). Returns ok=false on any failure. */
486
+ declare function totpVerify(deps: TotpDeps, input: {
487
+ userId: string;
488
+ code: string;
489
+ }): Promise<{
490
+ ok: boolean;
491
+ }>;
492
+
493
+ export { type AuthResult, type AuthVerifyDeps, Downstream, type DownstreamUser, type Locale, type MagicRequestDeps, type MagicVerifyDeps, type MagicVerifyResult, type OAuthLoginDeps, type OAuthLoginInput, type OAuthLoginResult, type OAuthStateData, type PasswordLoginDeps, type PasswordLoginInput, type PasswordLoginResult, type PlanetLoginConfig, type ProviderConfig, type RegisterResult, type RegisterVerifyDeps, type SessionClaims, type SessionStore, type StoreKind, type StoredCredential, type TotpDeps, _stores, authenticationOptions, buildAuthUrl, downstreamFromEnv, exchangeCode, fetchProfile, getProvider, getStore, hashPassword, jwks, loadConfig, newTotpSecret, oauthCallback, oauthStart, openEnc, openOAuthState, passkeyAuthVerify, passkeyRegisterVerify, passwordLogin, pkcePair, publicConfig, registrationOptions, requestMagicLink, sealEnc, sealOAuthState, signMagicToken, signSession, totpEnroll, totpKeyUri, totpVerify, verifyAuthentication, verifyMagicLink, verifyMagicToken, verifyPassword, verifyRegistration, verifySession, verifyTotp };
package/dist/index.js ADDED
@@ -0,0 +1,594 @@
1
+ // src/config.ts
2
+ import { readFileSync } from "fs";
3
+
4
+ // src/downstream.ts
5
+ var Downstream = class {
6
+ constructor(baseUrl, secret, timeoutMs = Number(process.env.PLANETLOGIN_DOWNSTREAM_TIMEOUT_MS) || 5e3, fetchImpl = fetch) {
7
+ this.baseUrl = baseUrl;
8
+ this.secret = secret;
9
+ this.timeoutMs = timeoutMs;
10
+ this.fetchImpl = fetchImpl;
11
+ }
12
+ baseUrl;
13
+ secret;
14
+ timeoutMs;
15
+ fetchImpl;
16
+ async call(path, body) {
17
+ const ctrl = new AbortController();
18
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
19
+ try {
20
+ const res = await this.fetchImpl(this.baseUrl.replace(/\/$/, "") + path, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.secret}` },
23
+ body: JSON.stringify(body),
24
+ signal: ctrl.signal
25
+ });
26
+ if (res.status === 404) return null;
27
+ if (!res.ok) throw new Error(`downstream ${path} \u2192 ${res.status}`);
28
+ const text = await res.text();
29
+ return text ? JSON.parse(text) : null;
30
+ } finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+ findUser(identifier) {
35
+ return this.call("/users/find", { identifier });
36
+ }
37
+ upsertUser(data) {
38
+ return this.call("/users/upsert", data);
39
+ }
40
+ /** Ask the integrator to deliver a magic link (it sends the email/SMS). */
41
+ deliverMagic(data) {
42
+ return this.call("/magic/deliver", data);
43
+ }
44
+ /** Passkeys (spec §4): the integrator stores WebAuthn credentials. */
45
+ passkeysFind(query) {
46
+ return this.call("/passkeys/find", query);
47
+ }
48
+ passkeysSave(data) {
49
+ return this.call("/passkeys/save", data);
50
+ }
51
+ /** TOTP (2FA): the integrator stores the per-user secret + enabled flag. */
52
+ totpGet(query) {
53
+ return this.call("/totp/find", query);
54
+ }
55
+ totpSave(data) {
56
+ return this.call("/totp/save", data);
57
+ }
58
+ };
59
+
60
+ // src/config.ts
61
+ var cfg = null;
62
+ function loadConfig() {
63
+ if (cfg) return cfg;
64
+ const raw = process.env.PLANETLOGIN_CONFIG;
65
+ if (!raw) throw new Error("PLANETLOGIN_CONFIG is required (path or inline JSON)");
66
+ const text = raw.trim().startsWith("{") ? raw : readFileSync(raw, "utf8");
67
+ const parsed = JSON.parse(text);
68
+ if (parsed.spec !== 1) throw new Error(`Unsupported config spec: ${parsed.spec}`);
69
+ if (!parsed.brand?.name) throw new Error("config.brand.name is required");
70
+ cfg = parsed;
71
+ return cfg;
72
+ }
73
+ function publicConfig(c = loadConfig()) {
74
+ return { spec: c.spec, brand: c.brand, providers: c.providers, copy: c.copy ?? {}, layout: c.layout ?? {} };
75
+ }
76
+ function downstreamFromEnv() {
77
+ const url = process.env.PLANETLOGIN_DOWNSTREAM_URL;
78
+ const secret = process.env.PLANETLOGIN_DOWNSTREAM_SECRET;
79
+ if (!url || !secret) throw new Error("PLANETLOGIN_DOWNSTREAM_URL and _SECRET are required");
80
+ return new Downstream(url, secret);
81
+ }
82
+
83
+ // src/jwt.ts
84
+ import {
85
+ SignJWT,
86
+ jwtVerify,
87
+ exportJWK,
88
+ generateKeyPair,
89
+ importPKCS8,
90
+ createLocalJWKSet
91
+ } from "jose";
92
+ var cache = null;
93
+ async function getKeys() {
94
+ if (cache) return cache;
95
+ let priv;
96
+ const pem = process.env.PLANETLOGIN_JWT_PRIVATE_KEY_PEM;
97
+ if (pem) {
98
+ priv = await importPKCS8(pem, "EdDSA");
99
+ } else {
100
+ priv = (await generateKeyPair("EdDSA", { extractable: true })).privateKey;
101
+ }
102
+ const full = await exportJWK(priv);
103
+ const { d, ...pub } = full;
104
+ const kid = process.env.PLANETLOGIN_JWT_KID || "dev";
105
+ cache = { priv, pubJwk: { ...pub, kid, use: "sig", alg: "EdDSA" }, kid };
106
+ return cache;
107
+ }
108
+ async function jwks() {
109
+ const k = await getKeys();
110
+ return { keys: [k.pubJwk] };
111
+ }
112
+ async function signSession(claims, opts = {}) {
113
+ const k = await getKeys();
114
+ return new SignJWT({ email: claims.email, name: claims.name, locale: claims.locale }).setProtectedHeader({ alg: "EdDSA", kid: k.kid }).setSubject(claims.sub).setIssuedAt().setExpirationTime(`${opts.ttlSeconds ?? 3600}s`).setIssuer(opts.issuer ?? "planetlogin").setAudience(opts.audience ?? "planetlogin").sign(k.priv);
115
+ }
116
+ async function verifySession(token) {
117
+ const set = await jwks();
118
+ const keySet = createLocalJWKSet(set);
119
+ const { payload } = await jwtVerify(token, keySet, { issuer: "planetlogin", audience: "planetlogin" });
120
+ return payload;
121
+ }
122
+ async function signMagicToken(identifier, ttlSeconds = 900) {
123
+ const k = await getKeys();
124
+ return new SignJWT({ purpose: "magic" }).setProtectedHeader({ alg: "EdDSA", kid: k.kid }).setSubject(identifier).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).setIssuer("planetlogin").setAudience("planetlogin:magic").sign(k.priv);
125
+ }
126
+ async function verifyMagicToken(token) {
127
+ try {
128
+ const set = await jwks();
129
+ const { payload } = await jwtVerify(token, createLocalJWKSet(set), {
130
+ issuer: "planetlogin",
131
+ audience: "planetlogin:magic"
132
+ });
133
+ if (payload.purpose !== "magic" || !payload.sub || !payload.jti) return null;
134
+ return { identifier: payload.sub, jti: payload.jti };
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ // src/password.ts
141
+ import { argon2id, argon2Verify } from "hash-wasm";
142
+ import bcrypt from "bcryptjs";
143
+ import { scrypt as nodeScrypt, pbkdf2 as nodePbkdf2, timingSafeEqual } from "crypto";
144
+ import { promisify } from "util";
145
+ var scryptAsync = promisify(nodeScrypt);
146
+ var pbkdf2Async = promisify(nodePbkdf2);
147
+ var PARAMS = { parallelism: 1, iterations: 2, memorySize: 19456, hashLength: 32 };
148
+ async function hashPassword(password) {
149
+ const salt = crypto.getRandomValues(new Uint8Array(16));
150
+ return argon2id({ password, salt, ...PARAMS, outputType: "encoded" });
151
+ }
152
+ var safeEq = (a, b) => a.length === b.length && timingSafeEqual(a, b);
153
+ async function verifyPassword(password, stored) {
154
+ try {
155
+ if (!stored) return false;
156
+ if (stored.startsWith("$argon2")) {
157
+ return await argon2Verify({ password, hash: stored });
158
+ }
159
+ if (/^\$2[aby]\$/.test(stored)) {
160
+ return await bcrypt.compare(password, stored);
161
+ }
162
+ if (stored.startsWith("$scrypt$")) {
163
+ const [, , params, saltB64, hashB64] = stored.split("$");
164
+ const p = Object.fromEntries(params.split(",").map((kv) => kv.split("=")));
165
+ const salt = Buffer.from(saltB64, "base64");
166
+ const expected = Buffer.from(hashB64, "base64");
167
+ const N = p.ln ? 2 ** Number(p.ln) : Number(p.N ?? 16384);
168
+ const derived = await scryptAsync(password, salt, expected.length, { N, r: Number(p.r ?? 8), p: Number(p.p ?? 1), maxmem: 256 * N * Number(p.r ?? 8) });
169
+ return safeEq(derived, expected);
170
+ }
171
+ let m = stored.match(/^pbkdf2_(sha\d+)\$(\d+)\$([^$]+)\$(.+)$/);
172
+ if (m) {
173
+ const [, algo, iter, salt, hashB64] = m;
174
+ const expected = Buffer.from(hashB64, "base64");
175
+ const derived = await pbkdf2Async(password, Buffer.from(salt, "utf8"), Number(iter), expected.length, algo.replace("sha", "sha"));
176
+ return safeEq(derived, expected);
177
+ }
178
+ m = stored.match(/^\$pbkdf2-(sha\d+)\$(\d+)\$([^$]+)\$(.+)$/);
179
+ if (m) {
180
+ const [, algo, iter, saltB64, hashB64] = m;
181
+ const salt = Buffer.from(saltB64.replace(/\./g, "+"), "base64");
182
+ const expected = Buffer.from(hashB64.replace(/\./g, "+"), "base64");
183
+ const derived = await pbkdf2Async(password, salt, Number(iter), expected.length, algo);
184
+ return safeEq(derived, expected);
185
+ }
186
+ return false;
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ // src/store.ts
193
+ var NoneStore = class {
194
+ async get() {
195
+ return null;
196
+ }
197
+ async set() {
198
+ }
199
+ async delete() {
200
+ }
201
+ async claimOnce() {
202
+ return true;
203
+ }
204
+ };
205
+ var MemoryStore = class {
206
+ m = /* @__PURE__ */ new Map();
207
+ alive(key) {
208
+ const e = this.m.get(key);
209
+ if (!e) return void 0;
210
+ if (e.exp <= Date.now()) {
211
+ this.m.delete(key);
212
+ return void 0;
213
+ }
214
+ return e;
215
+ }
216
+ async get(key) {
217
+ return this.alive(key)?.v ?? null;
218
+ }
219
+ async set(key, value, ttlSeconds) {
220
+ this.m.set(key, { v: value, exp: Date.now() + ttlSeconds * 1e3 });
221
+ }
222
+ async delete(key) {
223
+ this.m.delete(key);
224
+ }
225
+ async claimOnce(key, ttlSeconds) {
226
+ if (this.alive(key)) return false;
227
+ await this.set(key, "1", ttlSeconds);
228
+ return true;
229
+ }
230
+ };
231
+ var cached = null;
232
+ function getStore(kind = process.env.PLANETLOGIN_SESSION_STORE || "none") {
233
+ if (cached) return cached;
234
+ switch (kind) {
235
+ case "memory":
236
+ cached = new MemoryStore();
237
+ break;
238
+ case "none":
239
+ cached = new NoneStore();
240
+ break;
241
+ default:
242
+ throw new Error(`session.store "${kind}" not implemented in this flavor (use none|memory)`);
243
+ }
244
+ return cached;
245
+ }
246
+ var _stores = { NoneStore, MemoryStore };
247
+
248
+ // src/oauth.ts
249
+ import { createHash, randomBytes } from "crypto";
250
+ var b64url = (b) => b.toString("base64url");
251
+ function pkcePair() {
252
+ const verifier = b64url(randomBytes(32));
253
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
254
+ return { verifier, challenge };
255
+ }
256
+ var REGISTRY = {
257
+ google: {
258
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
259
+ tokenUrl: "https://oauth2.googleapis.com/token",
260
+ userinfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
261
+ scopes: ["openid", "email", "profile"],
262
+ mapProfile: (r) => ({ providerUserId: r.sub, email: r.email, name: r.name })
263
+ },
264
+ github: {
265
+ authorizeUrl: "https://github.com/login/oauth/authorize",
266
+ tokenUrl: "https://github.com/login/oauth/access_token",
267
+ userinfoUrl: "https://api.github.com/user",
268
+ scopes: ["read:user", "user:email"],
269
+ mapProfile: (r) => ({ providerUserId: String(r.id), email: r.email, name: r.name || r.login })
270
+ }
271
+ };
272
+ function getProvider(id) {
273
+ const env = (s) => process.env[`PLANETLOGIN_OAUTH_${id.toUpperCase()}_${s}`];
274
+ const base = REGISTRY[id] ?? {
275
+ authorizeUrl: env("AUTHORIZE_URL") || "",
276
+ tokenUrl: env("TOKEN_URL") || "",
277
+ userinfoUrl: env("USERINFO_URL") || "",
278
+ scopes: (env("SCOPES") || "openid email profile").split(" "),
279
+ mapProfile: (r) => ({ providerUserId: r.sub ?? String(r.id), email: r.email, name: r.name })
280
+ };
281
+ return {
282
+ ...base,
283
+ authorizeUrl: env("AUTHORIZE_URL") || base.authorizeUrl,
284
+ tokenUrl: env("TOKEN_URL") || base.tokenUrl,
285
+ userinfoUrl: env("USERINFO_URL") || base.userinfoUrl
286
+ };
287
+ }
288
+ function buildAuthUrl(p, a) {
289
+ const u = new URL(p.authorizeUrl);
290
+ u.searchParams.set("response_type", "code");
291
+ u.searchParams.set("client_id", a.clientId);
292
+ u.searchParams.set("redirect_uri", a.redirectUri);
293
+ u.searchParams.set("scope", p.scopes.join(" "));
294
+ u.searchParams.set("state", a.state);
295
+ u.searchParams.set("code_challenge", a.challenge);
296
+ u.searchParams.set("code_challenge_method", "S256");
297
+ return u.toString();
298
+ }
299
+ function oauthStart(p, a) {
300
+ const { verifier, challenge } = pkcePair();
301
+ const state = b64url(randomBytes(16));
302
+ return { url: buildAuthUrl(p, { ...a, state, challenge }), state, codeVerifier: verifier };
303
+ }
304
+ async function exchangeCode(p, a) {
305
+ const res = await fetch(p.tokenUrl, {
306
+ method: "POST",
307
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
308
+ body: new URLSearchParams({
309
+ grant_type: "authorization_code",
310
+ code: a.code,
311
+ redirect_uri: a.redirectUri,
312
+ client_id: a.clientId,
313
+ client_secret: a.clientSecret,
314
+ code_verifier: a.codeVerifier
315
+ })
316
+ });
317
+ if (!res.ok) throw new Error(`token exchange \u2192 ${res.status}`);
318
+ const tok = await res.json();
319
+ if (!tok.access_token) throw new Error("no access_token");
320
+ return tok.access_token;
321
+ }
322
+ async function fetchProfile(p, accessToken) {
323
+ const res = await fetch(p.userinfoUrl, {
324
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json", "user-agent": "planetlogin" }
325
+ });
326
+ if (!res.ok) throw new Error(`userinfo \u2192 ${res.status}`);
327
+ return p.mapProfile(await res.json());
328
+ }
329
+
330
+ // src/oauthState.ts
331
+ import { EncryptJWT, jwtDecrypt, base64url } from "jose";
332
+ var stateKey = null;
333
+ function getStateKey() {
334
+ if (stateKey) return stateKey;
335
+ const env = process.env.PLANETLOGIN_STATE_KEY;
336
+ stateKey = env ? base64url.decode(env) : crypto.getRandomValues(new Uint8Array(32));
337
+ if (stateKey.length !== 32) throw new Error("PLANETLOGIN_STATE_KEY must decode to 32 bytes");
338
+ return stateKey;
339
+ }
340
+ async function sealEnc(data, ttlSeconds = 600) {
341
+ return new EncryptJWT({ d: data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).encrypt(getStateKey());
342
+ }
343
+ async function openEnc(token) {
344
+ try {
345
+ const { payload } = await jwtDecrypt(token, getStateKey());
346
+ return payload.d;
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+ var sealOAuthState = (d, ttl = 600) => sealEnc(d, ttl);
352
+ var openOAuthState = (t) => openEnc(t);
353
+
354
+ // src/passkey.ts
355
+ import {
356
+ generateRegistrationOptions,
357
+ verifyRegistrationResponse,
358
+ generateAuthenticationOptions,
359
+ verifyAuthenticationResponse
360
+ } from "@simplewebauthn/server";
361
+ var b64u = { enc: (b) => Buffer.from(b).toString("base64url"), dec: (s) => new Uint8Array(Buffer.from(s, "base64url")) };
362
+ function registrationOptions(a) {
363
+ return generateRegistrationOptions({
364
+ rpName: a.rpName,
365
+ rpID: a.rpID,
366
+ userID: new TextEncoder().encode(a.userId),
367
+ userName: a.userName,
368
+ attestationType: "none",
369
+ excludeCredentials: (a.existing ?? []).map((c) => ({ id: c.id, transports: c.transports })),
370
+ authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" }
371
+ });
372
+ }
373
+ function authenticationOptions(a) {
374
+ return generateAuthenticationOptions({
375
+ rpID: a.rpID,
376
+ userVerification: "preferred",
377
+ allowCredentials: (a.allow ?? []).map((c) => ({ id: c.id, transports: c.transports }))
378
+ });
379
+ }
380
+ async function verifyRegistration(a) {
381
+ const v = await verifyRegistrationResponse({
382
+ response: a.response,
383
+ expectedChallenge: a.expectedChallenge,
384
+ expectedOrigin: a.origin,
385
+ expectedRPID: a.rpID
386
+ });
387
+ if (!v.verified || !v.registrationInfo) return { verified: false };
388
+ const c = v.registrationInfo.credential;
389
+ return { verified: true, credential: { id: c.id, publicKey: b64u.enc(c.publicKey), counter: c.counter, transports: c.transports } };
390
+ }
391
+ async function verifyAuthentication(a) {
392
+ const v = await verifyAuthenticationResponse({
393
+ response: a.response,
394
+ expectedChallenge: a.expectedChallenge,
395
+ expectedOrigin: a.origin,
396
+ expectedRPID: a.rpID,
397
+ credential: { id: a.credential.id, publicKey: b64u.dec(a.credential.publicKey), counter: a.credential.counter, transports: a.credential.transports }
398
+ });
399
+ return { verified: v.verified, newCounter: v.authenticationInfo?.newCounter };
400
+ }
401
+
402
+ // src/totp.ts
403
+ import { TOTP, Secret } from "otpauth";
404
+ function build(secretB32, label = "user", issuer = "PlanetLogin") {
405
+ return new TOTP({ issuer, label, algorithm: "SHA1", digits: 6, period: 30, secret: Secret.fromBase32(secretB32) });
406
+ }
407
+ function newTotpSecret() {
408
+ return new Secret({ size: 20 }).base32;
409
+ }
410
+ function totpKeyUri(secretB32, label, issuer = "PlanetLogin") {
411
+ return build(secretB32, label, issuer).toString();
412
+ }
413
+ function verifyTotp(secretB32, code) {
414
+ try {
415
+ return build(secretB32).validate({ token: String(code).trim(), window: 1 }) !== null;
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+
421
+ // src/flows/passwordLogin.ts
422
+ async function passwordLogin(deps, input) {
423
+ let user;
424
+ try {
425
+ user = await deps.downstream.findUser(input.identifier);
426
+ } catch {
427
+ return { ok: false, code: "downstream_unavailable" };
428
+ }
429
+ if (!user || !user.passwordHash) {
430
+ return { ok: false, code: "invalid_credentials" };
431
+ }
432
+ const valid = await deps.verifyPassword(input.password, user.passwordHash);
433
+ if (!valid) {
434
+ return { ok: false, code: "invalid_credentials" };
435
+ }
436
+ if (user.totpEnabled) {
437
+ return { ok: "mfa", userId: user.id };
438
+ }
439
+ const token = await deps.signSession({
440
+ sub: user.id,
441
+ email: user.email,
442
+ name: user.name,
443
+ locale: input.locale ?? user.locale
444
+ });
445
+ return { ok: true, token, user: { id: user.id, email: user.email, name: user.name } };
446
+ }
447
+
448
+ // src/flows/magicLink.ts
449
+ async function requestMagicLink(deps, input) {
450
+ try {
451
+ const user = await deps.downstream.findUser(input.identifier);
452
+ if (user) {
453
+ const token = await deps.signMagicToken(input.identifier);
454
+ const link = `${input.baseUrl.replace(/\/$/, "")}/auth/magic/verify?token=${encodeURIComponent(token)}`;
455
+ await deps.downstream.deliverMagic({ identifier: input.identifier, link, locale: input.locale });
456
+ }
457
+ } catch {
458
+ }
459
+ return { accepted: true };
460
+ }
461
+ async function verifyMagicLink(deps, input) {
462
+ const m = await deps.verifyMagicToken(input.token);
463
+ if (!m) return { ok: false, code: "invalid_token" };
464
+ const fresh = await deps.store.claimOnce(`magic:${m.jti}`, 900);
465
+ if (!fresh) return { ok: false, code: "invalid_token" };
466
+ const user = await deps.downstream.findUser(m.identifier).catch(() => null);
467
+ if (!user) return { ok: false, code: "invalid_token" };
468
+ const session = await deps.signSession({
469
+ sub: user.id,
470
+ email: user.email,
471
+ name: user.name,
472
+ locale: input.locale ?? user.locale
473
+ });
474
+ return { ok: true, token: session, user: { id: user.id, email: user.email, name: user.name } };
475
+ }
476
+
477
+ // src/flows/oauthLogin.ts
478
+ async function oauthCallback(deps, input) {
479
+ try {
480
+ const accessToken = await exchangeCode(input.providerCfg, {
481
+ clientId: input.clientId,
482
+ clientSecret: input.clientSecret,
483
+ code: input.code,
484
+ codeVerifier: input.codeVerifier,
485
+ redirectUri: input.redirectUri
486
+ });
487
+ const id = await fetchProfile(input.providerCfg, accessToken);
488
+ if (!id.providerUserId) return { ok: false, code: "provider_error" };
489
+ const user = await deps.downstream.upsertUser({
490
+ provider: input.provider,
491
+ providerUserId: id.providerUserId,
492
+ email: id.email,
493
+ name: id.name
494
+ });
495
+ if (!user) return { ok: false, code: "provider_error" };
496
+ const token = await deps.signSession({
497
+ sub: user.id,
498
+ email: user.email ?? id.email,
499
+ name: user.name ?? id.name,
500
+ locale: input.locale
501
+ });
502
+ return { ok: true, token, user: { id: user.id, email: user.email, name: user.name } };
503
+ } catch {
504
+ return { ok: false, code: "provider_error" };
505
+ }
506
+ }
507
+
508
+ // src/flows/passkey.ts
509
+ async function passkeyRegisterVerify(deps, input) {
510
+ const v = await deps.verifyRegistration({
511
+ response: input.response,
512
+ expectedChallenge: input.expectedChallenge,
513
+ origin: input.origin,
514
+ rpID: input.rpID
515
+ }).catch(() => ({ verified: false }));
516
+ if (!v.verified || !v.credential) return { ok: false, code: "invalid_credentials" };
517
+ await deps.downstream.passkeysSave({ userId: input.userId, credential: v.credential });
518
+ return { ok: true };
519
+ }
520
+ async function passkeyAuthVerify(deps, input) {
521
+ const credentialId = input.response?.id;
522
+ if (!credentialId) return { ok: false, code: "invalid_credentials" };
523
+ const found = await deps.downstream.passkeysFind({ credentialId }).catch(() => null);
524
+ const credential = found?.credentials?.find((c) => c.id === credentialId);
525
+ if (!found || !credential) return { ok: false, code: "invalid_credentials" };
526
+ const v = await deps.verifyAuthentication({
527
+ response: input.response,
528
+ expectedChallenge: input.expectedChallenge,
529
+ origin: input.origin,
530
+ rpID: input.rpID,
531
+ credential
532
+ }).catch(() => ({ verified: false }));
533
+ if (!v.verified) return { ok: false, code: "invalid_credentials" };
534
+ if (typeof v.newCounter === "number") {
535
+ await deps.downstream.passkeysSave({ userId: found.userId, credential: { ...credential, counter: v.newCounter } }).catch(() => {
536
+ });
537
+ }
538
+ const token = await deps.signSession({ sub: found.userId, locale: input.locale });
539
+ return { ok: true, token, user: { id: found.userId } };
540
+ }
541
+
542
+ // src/flows/totp.ts
543
+ async function totpEnroll(deps, input) {
544
+ const secret = newTotpSecret();
545
+ await deps.downstream.totpSave({ userId: input.userId, secret, enabled: false });
546
+ return { secret, uri: totpKeyUri(secret, input.label, input.issuer) };
547
+ }
548
+ async function totpVerify(deps, input) {
549
+ const rec = await deps.downstream.totpGet({ userId: input.userId }).catch(() => null);
550
+ if (!rec?.secret) return { ok: false };
551
+ if (!verifyTotp(rec.secret, input.code)) return { ok: false };
552
+ if (!rec.enabled) await deps.downstream.totpSave({ userId: input.userId, secret: rec.secret, enabled: true });
553
+ return { ok: true };
554
+ }
555
+ export {
556
+ Downstream,
557
+ _stores,
558
+ authenticationOptions,
559
+ buildAuthUrl,
560
+ downstreamFromEnv,
561
+ exchangeCode,
562
+ fetchProfile,
563
+ getProvider,
564
+ getStore,
565
+ hashPassword,
566
+ jwks,
567
+ loadConfig,
568
+ newTotpSecret,
569
+ oauthCallback,
570
+ oauthStart,
571
+ openEnc,
572
+ openOAuthState,
573
+ passkeyAuthVerify,
574
+ passkeyRegisterVerify,
575
+ passwordLogin,
576
+ pkcePair,
577
+ publicConfig,
578
+ registrationOptions,
579
+ requestMagicLink,
580
+ sealEnc,
581
+ sealOAuthState,
582
+ signMagicToken,
583
+ signSession,
584
+ totpEnroll,
585
+ totpKeyUri,
586
+ totpVerify,
587
+ verifyAuthentication,
588
+ verifyMagicLink,
589
+ verifyMagicToken,
590
+ verifyPassword,
591
+ verifyRegistration,
592
+ verifySession,
593
+ verifyTotp
594
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@planetlogin/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "PlanetLogin auth core — framework-agnostic flows, JWT, crypto, downstream contract. Consumed by every flavor; the HTTP binding stays per-framework.",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "files": ["dist"],
16
+ "scripts": {
17
+ "build": "tsup",
18
+ "prepublishOnly": "tsup",
19
+ "test": "vitest run",
20
+ "typecheck": "tsc --noEmit",
21
+ "mock": "tsx mock-downstream/server.ts"
22
+ },
23
+ "dependencies": {
24
+ "@simplewebauthn/server": "^13.3.2",
25
+ "bcryptjs": "^2.4.3",
26
+ "hash-wasm": "^4.11.0",
27
+ "jose": "^5.9.6",
28
+ "otpauth": "^9.3.4"
29
+ },
30
+ "devDependencies": {
31
+ "@types/bcryptjs": "^2.4.6",
32
+ "@types/node": "^26.0.0",
33
+ "tsup": "^8.5.1",
34
+ "tsx": "^4.19.0",
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^2.1.0"
37
+ }
38
+ }