@planetlogin/core 0.1.0 → 0.3.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/index.d.ts +125 -4
- package/dist/index.js +182 -20
- package/dist/keygen.d.ts +1 -0
- package/dist/keygen.js +23 -0
- package/package.json +9 -2
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ interface Locale {
|
|
|
6
6
|
language?: string;
|
|
7
7
|
timezone?: string;
|
|
8
8
|
country?: string;
|
|
9
|
+
lat?: number;
|
|
10
|
+
lon?: number;
|
|
9
11
|
}
|
|
10
12
|
interface SessionClaims {
|
|
11
13
|
sub: string;
|
|
@@ -13,7 +15,9 @@ interface SessionClaims {
|
|
|
13
15
|
name?: string;
|
|
14
16
|
locale?: Locale;
|
|
15
17
|
}
|
|
16
|
-
|
|
18
|
+
type JwtAlg = 'EdDSA' | 'RS256' | 'ES256' | 'HS256';
|
|
19
|
+
/** Public JWK Set served at GET /auth/.well-known/jwks.json. Empty for HS256
|
|
20
|
+
* (symmetric secrets are shared out of band, never published). */
|
|
17
21
|
declare function jwks(): Promise<{
|
|
18
22
|
keys: JWK[];
|
|
19
23
|
}>;
|
|
@@ -22,7 +26,7 @@ declare function signSession(claims: SessionClaims, opts?: {
|
|
|
22
26
|
audience?: string;
|
|
23
27
|
ttlSeconds?: number;
|
|
24
28
|
}): Promise<string>;
|
|
25
|
-
/** Verify a session token against our own
|
|
29
|
+
/** Verify a session token against our own key material (what a downstream does). */
|
|
26
30
|
declare function verifySession(token: string): Promise<jose.JWTPayload>;
|
|
27
31
|
declare function signMagicToken(identifier: string, ttlSeconds?: number): Promise<string>;
|
|
28
32
|
/** Returns {identifier, jti} if the token is a valid, unexpired magic token, else null. */
|
|
@@ -39,6 +43,13 @@ interface DownstreamUser {
|
|
|
39
43
|
locale?: Locale;
|
|
40
44
|
totpEnabled?: boolean;
|
|
41
45
|
}
|
|
46
|
+
/** Per-user, dev-owned preferences (spec §4). `locale` is the typed, first-class
|
|
47
|
+
* piece PlanetLogin reads/writes (fly-to + i18n); `data` is an open bag the
|
|
48
|
+
* integrator can store any "that kind of info" in. */
|
|
49
|
+
interface UserPreferences {
|
|
50
|
+
locale?: Locale;
|
|
51
|
+
data?: Record<string, unknown>;
|
|
52
|
+
}
|
|
42
53
|
declare class Downstream {
|
|
43
54
|
private baseUrl;
|
|
44
55
|
private secret;
|
|
@@ -84,6 +95,13 @@ declare class Downstream {
|
|
|
84
95
|
secret: string;
|
|
85
96
|
enabled: boolean;
|
|
86
97
|
}): Promise<unknown>;
|
|
98
|
+
/** Preferences (spec §4): per-user locale + open data bag. Null when none. */
|
|
99
|
+
preferencesGet(query: {
|
|
100
|
+
userId: string;
|
|
101
|
+
}): Promise<UserPreferences | null>;
|
|
102
|
+
preferencesSave(data: {
|
|
103
|
+
userId: string;
|
|
104
|
+
} & UserPreferences): Promise<unknown>;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
interface PlanetLoginConfig {
|
|
@@ -125,6 +143,10 @@ interface PlanetLoginConfig {
|
|
|
125
143
|
showSearch?: boolean;
|
|
126
144
|
autoSpin?: boolean;
|
|
127
145
|
};
|
|
146
|
+
locale?: {
|
|
147
|
+
persist?: boolean;
|
|
148
|
+
flyToOnLogin?: boolean;
|
|
149
|
+
};
|
|
128
150
|
token?: {
|
|
129
151
|
issuer?: string;
|
|
130
152
|
audience?: string;
|
|
@@ -134,6 +156,26 @@ interface PlanetLoginConfig {
|
|
|
134
156
|
session?: {
|
|
135
157
|
store?: 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
|
|
136
158
|
};
|
|
159
|
+
security?: {
|
|
160
|
+
cors?: {
|
|
161
|
+
origins?: string[];
|
|
162
|
+
credentials?: boolean;
|
|
163
|
+
};
|
|
164
|
+
rateLimit?: {
|
|
165
|
+
login?: {
|
|
166
|
+
limit: number;
|
|
167
|
+
windowSeconds: number;
|
|
168
|
+
};
|
|
169
|
+
magic?: {
|
|
170
|
+
limit: number;
|
|
171
|
+
windowSeconds: number;
|
|
172
|
+
};
|
|
173
|
+
totp?: {
|
|
174
|
+
limit: number;
|
|
175
|
+
windowSeconds: number;
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
};
|
|
137
179
|
}
|
|
138
180
|
declare function loadConfig(): PlanetLoginConfig;
|
|
139
181
|
/** The public subset served at GET /auth/config (no secrets). */
|
|
@@ -176,6 +218,9 @@ declare function publicConfig(c?: PlanetLoginConfig): {
|
|
|
176
218
|
showSearch?: boolean;
|
|
177
219
|
autoSpin?: boolean;
|
|
178
220
|
};
|
|
221
|
+
locale: {
|
|
222
|
+
flyToOnLogin: boolean;
|
|
223
|
+
};
|
|
179
224
|
};
|
|
180
225
|
declare function downstreamFromEnv(): Downstream;
|
|
181
226
|
|
|
@@ -193,13 +238,19 @@ interface SessionStore {
|
|
|
193
238
|
delete(key: string): Promise<void>;
|
|
194
239
|
/** Atomically claim a key once: true if newly claimed, false if already present. */
|
|
195
240
|
claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
|
|
241
|
+
/** Atomically increment a counter, setting its TTL on first touch; returns the
|
|
242
|
+
* new count. Used by the rate limiter (fixed window). */
|
|
243
|
+
incr(key: string, ttlSeconds: number): Promise<number>;
|
|
196
244
|
}
|
|
197
|
-
/** No store: stateless. claimOnce always allows (single-use not enforced → TTL
|
|
245
|
+
/** No store: stateless. claimOnce always allows (single-use not enforced → TTL
|
|
246
|
+
* only); incr always returns 1 → the rate limiter never blocks. Both real
|
|
247
|
+
* single-use and rate limiting need a configured store (memory/redis/…). */
|
|
198
248
|
declare class NoneStore implements SessionStore {
|
|
199
249
|
get(): Promise<null>;
|
|
200
250
|
set(): Promise<void>;
|
|
201
251
|
delete(): Promise<void>;
|
|
202
252
|
claimOnce(): Promise<boolean>;
|
|
253
|
+
incr(): Promise<number>;
|
|
203
254
|
}
|
|
204
255
|
declare class MemoryStore implements SessionStore {
|
|
205
256
|
private m;
|
|
@@ -208,6 +259,7 @@ declare class MemoryStore implements SessionStore {
|
|
|
208
259
|
set(key: string, value: string, ttlSeconds: number): Promise<void>;
|
|
209
260
|
delete(key: string): Promise<void>;
|
|
210
261
|
claimOnce(key: string, ttlSeconds: number): Promise<boolean>;
|
|
262
|
+
incr(key: string, ttlSeconds: number): Promise<number>;
|
|
211
263
|
}
|
|
212
264
|
type StoreKind = 'none' | 'memory' | 'redis' | 'sqlite' | 'downstream';
|
|
213
265
|
/** Build the store from config. Only none+memory ship in this flavor; the rest
|
|
@@ -218,6 +270,57 @@ declare const _stores: {
|
|
|
218
270
|
MemoryStore: typeof MemoryStore;
|
|
219
271
|
};
|
|
220
272
|
|
|
273
|
+
interface RateLimitRule {
|
|
274
|
+
/** Max attempts allowed within the window. */
|
|
275
|
+
limit: number;
|
|
276
|
+
/** Window length in seconds. */
|
|
277
|
+
windowSeconds: number;
|
|
278
|
+
}
|
|
279
|
+
interface RateLimitDecision {
|
|
280
|
+
ok: boolean;
|
|
281
|
+
/** Attempts left in the current window (0 when blocked). */
|
|
282
|
+
remaining: number;
|
|
283
|
+
/** Seconds the caller should wait before retrying (only meaningful when blocked). */
|
|
284
|
+
retryAfter: number;
|
|
285
|
+
limit: number;
|
|
286
|
+
}
|
|
287
|
+
/** Built-in defaults (per key, fixed window). Tuned for human use; override via
|
|
288
|
+
* config.security.rateLimit or PLANETLOGIN_RATELIMIT_* env. */
|
|
289
|
+
declare const DEFAULT_RULES: Record<string, RateLimitRule>;
|
|
290
|
+
/**
|
|
291
|
+
* Consume one attempt for `key` under `rule`. `key` should already encode the
|
|
292
|
+
* action and a client discriminator (IP and/or identifier), e.g.
|
|
293
|
+
* `login:203.0.113.7` or `magic:user@x.com`. Builders below help.
|
|
294
|
+
*/
|
|
295
|
+
declare function rateLimit(store: SessionStore, key: string, rule: RateLimitRule): Promise<RateLimitDecision>;
|
|
296
|
+
/** Resolve a rule for an action: config/env override, else built-in default. */
|
|
297
|
+
declare function ruleFor(action: keyof typeof DEFAULT_RULES, overrides?: Partial<Record<string, RateLimitRule>>): RateLimitRule;
|
|
298
|
+
/** Build a rate-limit key from an action + the client discriminators available.
|
|
299
|
+
* Both IP and identifier are optional; whatever is present is hashed into the key. */
|
|
300
|
+
declare function rlKey(action: string, parts: {
|
|
301
|
+
ip?: string | null;
|
|
302
|
+
identifier?: string | null;
|
|
303
|
+
}): string;
|
|
304
|
+
|
|
305
|
+
interface CorsConfig {
|
|
306
|
+
/** Allowed origins, or ['*'] to reflect any. */
|
|
307
|
+
origins: string[];
|
|
308
|
+
/** Send Access-Control-Allow-Credentials (cookies). Default true. */
|
|
309
|
+
credentials?: boolean;
|
|
310
|
+
}
|
|
311
|
+
/** Read the allowlist from env (flavors may merge with config). */
|
|
312
|
+
declare function corsFromEnv(): CorsConfig;
|
|
313
|
+
/** Is `origin` allowed under this config? */
|
|
314
|
+
declare function originAllowed(origin: string | null | undefined, cfg: CorsConfig): boolean;
|
|
315
|
+
/**
|
|
316
|
+
* CORS response headers for a given request origin. Returns {} when the origin
|
|
317
|
+
* isn't allowed (no CORS headers → the browser blocks the cross-origin read).
|
|
318
|
+
* Always includes `Vary: Origin` so caches don't serve the wrong ACAO.
|
|
319
|
+
*/
|
|
320
|
+
declare function corsHeaders(origin: string | null | undefined, cfg: CorsConfig): Record<string, string>;
|
|
321
|
+
/** True for a CORS preflight (OPTIONS + Access-Control-Request-Method). */
|
|
322
|
+
declare function isPreflight(method: string, requestMethodHeader: string | null | undefined): boolean;
|
|
323
|
+
|
|
221
324
|
interface ProviderConfig {
|
|
222
325
|
authorizeUrl: string;
|
|
223
326
|
tokenUrl: string;
|
|
@@ -347,6 +450,24 @@ type PasswordLoginResult = {
|
|
|
347
450
|
};
|
|
348
451
|
declare function passwordLogin(deps: PasswordLoginDeps, input: PasswordLoginInput): Promise<PasswordLoginResult>;
|
|
349
452
|
|
|
453
|
+
interface PreferencesDeps {
|
|
454
|
+
downstream: Pick<Downstream, 'preferencesGet' | 'preferencesSave'>;
|
|
455
|
+
}
|
|
456
|
+
/** Keep only the known, well-typed locale fields (drop anything else). */
|
|
457
|
+
declare function sanitizeLocale(input: unknown): Locale | undefined;
|
|
458
|
+
/** Read the user's saved preferences. Always resolves (empty object if none/error). */
|
|
459
|
+
declare function getPreferences(deps: PreferencesDeps, input: {
|
|
460
|
+
userId: string;
|
|
461
|
+
}): Promise<UserPreferences>;
|
|
462
|
+
/** Write the user's preferences. No-op when there's nothing valid to write. */
|
|
463
|
+
declare function savePreferences(deps: PreferencesDeps, input: {
|
|
464
|
+
userId: string;
|
|
465
|
+
locale?: unknown;
|
|
466
|
+
data?: unknown;
|
|
467
|
+
}): Promise<{
|
|
468
|
+
saved: boolean;
|
|
469
|
+
}>;
|
|
470
|
+
|
|
350
471
|
interface MagicRequestDeps {
|
|
351
472
|
downstream: Downstream;
|
|
352
473
|
signMagicToken: (identifier: string) => Promise<string>;
|
|
@@ -490,4 +611,4 @@ declare function totpVerify(deps: TotpDeps, input: {
|
|
|
490
611
|
ok: boolean;
|
|
491
612
|
}>;
|
|
492
613
|
|
|
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 };
|
|
614
|
+
export { type AuthResult, type AuthVerifyDeps, type CorsConfig, DEFAULT_RULES, Downstream, type DownstreamUser, type JwtAlg, 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 PreferencesDeps, type ProviderConfig, type RateLimitDecision, type RateLimitRule, type RegisterResult, type RegisterVerifyDeps, type SessionClaims, type SessionStore, type StoreKind, type StoredCredential, type TotpDeps, type UserPreferences, _stores, authenticationOptions, buildAuthUrl, corsFromEnv, corsHeaders, downstreamFromEnv, exchangeCode, fetchProfile, getPreferences, getProvider, getStore, hashPassword, isPreflight, jwks, loadConfig, newTotpSecret, oauthCallback, oauthStart, openEnc, openOAuthState, originAllowed, passkeyAuthVerify, passkeyRegisterVerify, passwordLogin, pkcePair, publicConfig, rateLimit, registrationOptions, requestMagicLink, rlKey, ruleFor, sanitizeLocale, savePreferences, sealEnc, sealOAuthState, signMagicToken, signSession, totpEnroll, totpKeyUri, totpVerify, verifyAuthentication, verifyMagicLink, verifyMagicToken, verifyPassword, verifyRegistration, verifySession, verifyTotp };
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,13 @@ var Downstream = class {
|
|
|
55
55
|
totpSave(data) {
|
|
56
56
|
return this.call("/totp/save", data);
|
|
57
57
|
}
|
|
58
|
+
/** Preferences (spec §4): per-user locale + open data bag. Null when none. */
|
|
59
|
+
preferencesGet(query) {
|
|
60
|
+
return this.call("/preferences/find", query);
|
|
61
|
+
}
|
|
62
|
+
preferencesSave(data) {
|
|
63
|
+
return this.call("/preferences/save", data);
|
|
64
|
+
}
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
// src/config.ts
|
|
@@ -71,7 +78,15 @@ function loadConfig() {
|
|
|
71
78
|
return cfg;
|
|
72
79
|
}
|
|
73
80
|
function publicConfig(c = loadConfig()) {
|
|
74
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
spec: c.spec,
|
|
83
|
+
brand: c.brand,
|
|
84
|
+
providers: c.providers,
|
|
85
|
+
copy: c.copy ?? {},
|
|
86
|
+
layout: c.layout ?? {},
|
|
87
|
+
// Only the client-relevant gate (flyToOnLogin drives the post-login fly-to).
|
|
88
|
+
locale: { flyToOnLogin: c.locale?.flyToOnLogin ?? false }
|
|
89
|
+
};
|
|
75
90
|
}
|
|
76
91
|
function downstreamFromEnv() {
|
|
77
92
|
const url = process.env.PLANETLOGIN_DOWNSTREAM_URL;
|
|
@@ -86,47 +101,84 @@ import {
|
|
|
86
101
|
jwtVerify,
|
|
87
102
|
exportJWK,
|
|
88
103
|
generateKeyPair,
|
|
104
|
+
generateSecret,
|
|
89
105
|
importPKCS8,
|
|
90
106
|
createLocalJWKSet
|
|
91
107
|
} from "jose";
|
|
108
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
109
|
+
var ASYMMETRIC = ["EdDSA", "RS256", "ES256"];
|
|
92
110
|
var cache = null;
|
|
111
|
+
function resolveAlg() {
|
|
112
|
+
const a = process.env.PLANETLOGIN_JWT_ALG || "EdDSA";
|
|
113
|
+
if (!ASYMMETRIC.includes(a) && a !== "HS256") {
|
|
114
|
+
throw new Error(`Unsupported PLANETLOGIN_JWT_ALG "${a}" (use EdDSA|RS256|ES256|HS256)`);
|
|
115
|
+
}
|
|
116
|
+
return a;
|
|
117
|
+
}
|
|
118
|
+
function loadPrivatePem() {
|
|
119
|
+
const v = process.env.PLANETLOGIN_JWT_PRIVATE_KEY ?? process.env.PLANETLOGIN_JWT_PRIVATE_KEY_PEM;
|
|
120
|
+
if (!v) return null;
|
|
121
|
+
return v.includes("-----BEGIN") ? v : readFileSync2(v, "utf8");
|
|
122
|
+
}
|
|
123
|
+
function loadSecret() {
|
|
124
|
+
const v = process.env.PLANETLOGIN_JWT_SECRET;
|
|
125
|
+
if (!v) return null;
|
|
126
|
+
const raw = v.includes("\n") || v.length > 256 ? v : v.startsWith("/") ? readFileSync2(v, "utf8").trim() : v;
|
|
127
|
+
return new TextEncoder().encode(raw);
|
|
128
|
+
}
|
|
93
129
|
async function getKeys() {
|
|
94
130
|
if (cache) return cache;
|
|
131
|
+
const alg = resolveAlg();
|
|
132
|
+
const kid = process.env.PLANETLOGIN_JWT_KID || "dev";
|
|
133
|
+
if (alg === "HS256") {
|
|
134
|
+
let secret = loadSecret();
|
|
135
|
+
if (!secret) {
|
|
136
|
+
console.warn("[planetlogin] WARNING: no PLANETLOGIN_JWT_SECRET \u2014 using an EPHEMERAL HS256 secret. Tokens die on restart and cannot be verified across instances. Do not use in production.");
|
|
137
|
+
secret = await generateSecret("HS256", { extractable: true });
|
|
138
|
+
}
|
|
139
|
+
cache = { alg, kid, sign: secret, verify: secret, pubJwk: null };
|
|
140
|
+
return cache;
|
|
141
|
+
}
|
|
95
142
|
let priv;
|
|
96
|
-
const pem =
|
|
143
|
+
const pem = loadPrivatePem();
|
|
97
144
|
if (pem) {
|
|
98
|
-
priv = await importPKCS8(pem,
|
|
145
|
+
priv = await importPKCS8(pem, alg);
|
|
99
146
|
} else {
|
|
100
|
-
|
|
147
|
+
console.warn(`[planetlogin] WARNING: no PLANETLOGIN_JWT_PRIVATE_KEY \u2014 using an EPHEMERAL ${alg} keypair. Do not use in production.`);
|
|
148
|
+
priv = (await generateKeyPair(alg, { extractable: true })).privateKey;
|
|
101
149
|
}
|
|
102
150
|
const full = await exportJWK(priv);
|
|
103
|
-
const { d, ...pub } = full;
|
|
104
|
-
|
|
105
|
-
cache = { priv, pubJwk: { ...pub, kid, use: "sig", alg: "EdDSA" }, kid };
|
|
151
|
+
const { d, p, q, dp, dq, qi, ...pub } = full;
|
|
152
|
+
cache = { alg, kid, sign: priv, verify: priv, pubJwk: { ...pub, kid, use: "sig", alg } };
|
|
106
153
|
return cache;
|
|
107
154
|
}
|
|
108
155
|
async function jwks() {
|
|
109
156
|
const k = await getKeys();
|
|
110
|
-
return { keys: [k.pubJwk] };
|
|
157
|
+
return { keys: k.pubJwk ? [k.pubJwk] : [] };
|
|
158
|
+
}
|
|
159
|
+
async function verifier() {
|
|
160
|
+
const k = await getKeys();
|
|
161
|
+
if (k.alg === "HS256") return k.verify;
|
|
162
|
+
return createLocalJWKSet({ keys: [k.pubJwk] });
|
|
111
163
|
}
|
|
112
164
|
async function signSession(claims, opts = {}) {
|
|
113
165
|
const k = await getKeys();
|
|
114
|
-
return new SignJWT({ email: claims.email, name: claims.name, locale: claims.locale }).setProtectedHeader({ alg:
|
|
166
|
+
return new SignJWT({ email: claims.email, name: claims.name, locale: claims.locale }).setProtectedHeader({ alg: k.alg, kid: k.kid }).setSubject(claims.sub).setIssuedAt().setExpirationTime(`${opts.ttlSeconds ?? 3600}s`).setIssuer(opts.issuer ?? "planetlogin").setAudience(opts.audience ?? "planetlogin").sign(k.sign);
|
|
115
167
|
}
|
|
116
168
|
async function verifySession(token) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
169
|
+
const { payload } = await jwtVerify(token, await verifier(), {
|
|
170
|
+
issuer: "planetlogin",
|
|
171
|
+
audience: "planetlogin"
|
|
172
|
+
});
|
|
120
173
|
return payload;
|
|
121
174
|
}
|
|
122
175
|
async function signMagicToken(identifier, ttlSeconds = 900) {
|
|
123
176
|
const k = await getKeys();
|
|
124
|
-
return new SignJWT({ purpose: "magic" }).setProtectedHeader({ alg:
|
|
177
|
+
return new SignJWT({ purpose: "magic" }).setProtectedHeader({ alg: k.alg, kid: k.kid }).setSubject(identifier).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime(`${ttlSeconds}s`).setIssuer("planetlogin").setAudience("planetlogin:magic").sign(k.sign);
|
|
125
178
|
}
|
|
126
179
|
async function verifyMagicToken(token) {
|
|
127
180
|
try {
|
|
128
|
-
const
|
|
129
|
-
const { payload } = await jwtVerify(token, createLocalJWKSet(set), {
|
|
181
|
+
const { payload } = await jwtVerify(token, await verifier(), {
|
|
130
182
|
issuer: "planetlogin",
|
|
131
183
|
audience: "planetlogin:magic"
|
|
132
184
|
});
|
|
@@ -201,6 +253,9 @@ var NoneStore = class {
|
|
|
201
253
|
async claimOnce() {
|
|
202
254
|
return true;
|
|
203
255
|
}
|
|
256
|
+
async incr() {
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
204
259
|
};
|
|
205
260
|
var MemoryStore = class {
|
|
206
261
|
m = /* @__PURE__ */ new Map();
|
|
@@ -227,6 +282,16 @@ var MemoryStore = class {
|
|
|
227
282
|
await this.set(key, "1", ttlSeconds);
|
|
228
283
|
return true;
|
|
229
284
|
}
|
|
285
|
+
async incr(key, ttlSeconds) {
|
|
286
|
+
const e = this.alive(key);
|
|
287
|
+
if (!e) {
|
|
288
|
+
await this.set(key, "1", ttlSeconds);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
const n = Number(e.v) + 1;
|
|
292
|
+
e.v = String(n);
|
|
293
|
+
return n;
|
|
294
|
+
}
|
|
230
295
|
};
|
|
231
296
|
var cached = null;
|
|
232
297
|
function getStore(kind = process.env.PLANETLOGIN_SESSION_STORE || "none") {
|
|
@@ -245,13 +310,75 @@ function getStore(kind = process.env.PLANETLOGIN_SESSION_STORE || "none") {
|
|
|
245
310
|
}
|
|
246
311
|
var _stores = { NoneStore, MemoryStore };
|
|
247
312
|
|
|
313
|
+
// src/ratelimit.ts
|
|
314
|
+
var DEFAULT_RULES = {
|
|
315
|
+
login: { limit: 10, windowSeconds: 300 },
|
|
316
|
+
// 10 / 5 min
|
|
317
|
+
magic: { limit: 5, windowSeconds: 900 },
|
|
318
|
+
// 5 / 15 min
|
|
319
|
+
totp: { limit: 10, windowSeconds: 300 }
|
|
320
|
+
};
|
|
321
|
+
async function rateLimit(store, key, rule) {
|
|
322
|
+
try {
|
|
323
|
+
const count = await store.incr(`rl:${key}`, rule.windowSeconds);
|
|
324
|
+
const remaining = Math.max(0, rule.limit - count);
|
|
325
|
+
if (count > rule.limit) {
|
|
326
|
+
return { ok: false, remaining: 0, retryAfter: rule.windowSeconds, limit: rule.limit };
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, remaining, retryAfter: 0, limit: rule.limit };
|
|
329
|
+
} catch {
|
|
330
|
+
return { ok: true, remaining: rule.limit, retryAfter: 0, limit: rule.limit };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function ruleFor(action, overrides) {
|
|
334
|
+
const envLimit = process.env[`PLANETLOGIN_RATELIMIT_${action.toUpperCase()}_LIMIT`];
|
|
335
|
+
const envWindow = process.env[`PLANETLOGIN_RATELIMIT_${action.toUpperCase()}_WINDOW`];
|
|
336
|
+
const base = overrides?.[action] ?? DEFAULT_RULES[action];
|
|
337
|
+
return {
|
|
338
|
+
limit: envLimit ? Number(envLimit) : base.limit,
|
|
339
|
+
windowSeconds: envWindow ? Number(envWindow) : base.windowSeconds
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function rlKey(action, parts) {
|
|
343
|
+
const disc = [parts.ip, parts.identifier].filter(Boolean).join("|") || "anon";
|
|
344
|
+
return `${action}:${disc}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/cors.ts
|
|
348
|
+
var METHODS = "GET, POST, OPTIONS";
|
|
349
|
+
var ALLOW_HEADERS = "content-type, authorization";
|
|
350
|
+
function corsFromEnv() {
|
|
351
|
+
const raw = process.env.PLANETLOGIN_CORS_ORIGINS ?? "";
|
|
352
|
+
const origins = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
353
|
+
const credentials = process.env.PLANETLOGIN_CORS_CREDENTIALS !== "false";
|
|
354
|
+
return { origins, credentials };
|
|
355
|
+
}
|
|
356
|
+
function originAllowed(origin, cfg2) {
|
|
357
|
+
if (!origin) return false;
|
|
358
|
+
if (cfg2.origins.includes(origin)) return true;
|
|
359
|
+
return cfg2.origins.includes("*") && cfg2.credentials === false;
|
|
360
|
+
}
|
|
361
|
+
function corsHeaders(origin, cfg2) {
|
|
362
|
+
const h = { vary: "Origin" };
|
|
363
|
+
if (!originAllowed(origin, cfg2)) return h;
|
|
364
|
+
h["access-control-allow-origin"] = cfg2.credentials === false && cfg2.origins.includes("*") ? "*" : origin;
|
|
365
|
+
if (cfg2.credentials !== false) h["access-control-allow-credentials"] = "true";
|
|
366
|
+
h["access-control-allow-methods"] = METHODS;
|
|
367
|
+
h["access-control-allow-headers"] = ALLOW_HEADERS;
|
|
368
|
+
h["access-control-max-age"] = "600";
|
|
369
|
+
return h;
|
|
370
|
+
}
|
|
371
|
+
function isPreflight(method, requestMethodHeader) {
|
|
372
|
+
return method === "OPTIONS" && !!requestMethodHeader;
|
|
373
|
+
}
|
|
374
|
+
|
|
248
375
|
// src/oauth.ts
|
|
249
376
|
import { createHash, randomBytes } from "crypto";
|
|
250
377
|
var b64url = (b) => b.toString("base64url");
|
|
251
378
|
function pkcePair() {
|
|
252
|
-
const
|
|
253
|
-
const challenge = b64url(createHash("sha256").update(
|
|
254
|
-
return { verifier, challenge };
|
|
379
|
+
const verifier2 = b64url(randomBytes(32));
|
|
380
|
+
const challenge = b64url(createHash("sha256").update(verifier2).digest());
|
|
381
|
+
return { verifier: verifier2, challenge };
|
|
255
382
|
}
|
|
256
383
|
var REGISTRY = {
|
|
257
384
|
google: {
|
|
@@ -297,9 +424,9 @@ function buildAuthUrl(p, a) {
|
|
|
297
424
|
return u.toString();
|
|
298
425
|
}
|
|
299
426
|
function oauthStart(p, a) {
|
|
300
|
-
const { verifier, challenge } = pkcePair();
|
|
427
|
+
const { verifier: verifier2, challenge } = pkcePair();
|
|
301
428
|
const state = b64url(randomBytes(16));
|
|
302
|
-
return { url: buildAuthUrl(p, { ...a, state, challenge }), state, codeVerifier:
|
|
429
|
+
return { url: buildAuthUrl(p, { ...a, state, challenge }), state, codeVerifier: verifier2 };
|
|
303
430
|
}
|
|
304
431
|
async function exchangeCode(p, a) {
|
|
305
432
|
const res = await fetch(p.tokenUrl, {
|
|
@@ -445,6 +572,30 @@ async function passwordLogin(deps, input) {
|
|
|
445
572
|
return { ok: true, token, user: { id: user.id, email: user.email, name: user.name } };
|
|
446
573
|
}
|
|
447
574
|
|
|
575
|
+
// src/flows/preferences.ts
|
|
576
|
+
function sanitizeLocale(input) {
|
|
577
|
+
if (!input || typeof input !== "object") return void 0;
|
|
578
|
+
const i = input;
|
|
579
|
+
const out = {};
|
|
580
|
+
if (typeof i.language === "string") out.language = i.language.slice(0, 16);
|
|
581
|
+
if (typeof i.timezone === "string") out.timezone = i.timezone.slice(0, 64);
|
|
582
|
+
if (typeof i.country === "string") out.country = i.country.slice(0, 2).toUpperCase();
|
|
583
|
+
if (typeof i.lat === "number" && Number.isFinite(i.lat)) out.lat = i.lat;
|
|
584
|
+
if (typeof i.lon === "number" && Number.isFinite(i.lon)) out.lon = i.lon;
|
|
585
|
+
return Object.keys(out).length ? out : void 0;
|
|
586
|
+
}
|
|
587
|
+
async function getPreferences(deps, input) {
|
|
588
|
+
const p = await deps.downstream.preferencesGet({ userId: input.userId }).catch(() => null);
|
|
589
|
+
return p ?? {};
|
|
590
|
+
}
|
|
591
|
+
async function savePreferences(deps, input) {
|
|
592
|
+
const locale = sanitizeLocale(input.locale);
|
|
593
|
+
const data = input.data && typeof input.data === "object" && !Array.isArray(input.data) ? input.data : void 0;
|
|
594
|
+
if (!locale && !data) return { saved: false };
|
|
595
|
+
await deps.downstream.preferencesSave({ userId: input.userId, ...locale ? { locale } : {}, ...data ? { data } : {} });
|
|
596
|
+
return { saved: true };
|
|
597
|
+
}
|
|
598
|
+
|
|
448
599
|
// src/flows/magicLink.ts
|
|
449
600
|
async function requestMagicLink(deps, input) {
|
|
450
601
|
try {
|
|
@@ -553,16 +704,21 @@ async function totpVerify(deps, input) {
|
|
|
553
704
|
return { ok: true };
|
|
554
705
|
}
|
|
555
706
|
export {
|
|
707
|
+
DEFAULT_RULES,
|
|
556
708
|
Downstream,
|
|
557
709
|
_stores,
|
|
558
710
|
authenticationOptions,
|
|
559
711
|
buildAuthUrl,
|
|
712
|
+
corsFromEnv,
|
|
713
|
+
corsHeaders,
|
|
560
714
|
downstreamFromEnv,
|
|
561
715
|
exchangeCode,
|
|
562
716
|
fetchProfile,
|
|
717
|
+
getPreferences,
|
|
563
718
|
getProvider,
|
|
564
719
|
getStore,
|
|
565
720
|
hashPassword,
|
|
721
|
+
isPreflight,
|
|
566
722
|
jwks,
|
|
567
723
|
loadConfig,
|
|
568
724
|
newTotpSecret,
|
|
@@ -570,13 +726,19 @@ export {
|
|
|
570
726
|
oauthStart,
|
|
571
727
|
openEnc,
|
|
572
728
|
openOAuthState,
|
|
729
|
+
originAllowed,
|
|
573
730
|
passkeyAuthVerify,
|
|
574
731
|
passkeyRegisterVerify,
|
|
575
732
|
passwordLogin,
|
|
576
733
|
pkcePair,
|
|
577
734
|
publicConfig,
|
|
735
|
+
rateLimit,
|
|
578
736
|
registrationOptions,
|
|
579
737
|
requestMagicLink,
|
|
738
|
+
rlKey,
|
|
739
|
+
ruleFor,
|
|
740
|
+
sanitizeLocale,
|
|
741
|
+
savePreferences,
|
|
580
742
|
sealEnc,
|
|
581
743
|
sealOAuthState,
|
|
582
744
|
signMagicToken,
|
package/dist/keygen.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/keygen.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/keygen.ts
|
|
4
|
+
import { generateKeyPair, exportPKCS8, exportJWK } from "jose";
|
|
5
|
+
import { writeFileSync } from "fs";
|
|
6
|
+
var { privateKey, publicKey } = await generateKeyPair("EdDSA", { extractable: true });
|
|
7
|
+
var pem = await exportPKCS8(privateKey);
|
|
8
|
+
var pubJwk = await exportJWK(publicKey);
|
|
9
|
+
var out = process.argv[2];
|
|
10
|
+
if (out) {
|
|
11
|
+
writeFileSync(out, pem, { mode: 384 });
|
|
12
|
+
console.error(`Private key written to ${out} (chmod 600).`);
|
|
13
|
+
console.error(`
|
|
14
|
+
Set: PLANETLOGIN_JWT_PRIVATE_KEY=${out}`);
|
|
15
|
+
} else {
|
|
16
|
+
process.stdout.write(pem);
|
|
17
|
+
console.error("\n# \u2191 EdDSA private key (PEM). Keep it secret. Then set one of:");
|
|
18
|
+
console.error("# PLANETLOGIN_JWT_PRIVATE_KEY=/path/to/this/key.pem (recommended: a secret file)");
|
|
19
|
+
console.error('# PLANETLOGIN_JWT_PRIVATE_KEY="$(cat key.pem)" (inline)');
|
|
20
|
+
}
|
|
21
|
+
console.error(`
|
|
22
|
+
# Public key (the kid in your JWKS will be PLANETLOGIN_JWT_KID, default "dev"):`);
|
|
23
|
+
console.error("# " + JSON.stringify({ ...pubJwk, use: "sig", alg: "EdDSA" }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planetlogin/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "PlanetLogin auth core — framework-agnostic flows, JWT, crypto, downstream contract. Consumed by every flavor; the HTTP binding stays per-framework.",
|
|
6
6
|
"exports": {
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
"main": "./dist/index.js",
|
|
13
13
|
"module": "./dist/index.js",
|
|
14
14
|
"types": "./dist/index.d.ts",
|
|
15
|
-
"
|
|
15
|
+
"bin": {
|
|
16
|
+
"planetlogin-keygen": "./dist/keygen.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
16
21
|
"scripts": {
|
|
17
22
|
"build": "tsup",
|
|
23
|
+
"prepare": "tsup",
|
|
18
24
|
"prepublishOnly": "tsup",
|
|
25
|
+
"keygen": "tsx bin/keygen.ts",
|
|
19
26
|
"test": "vitest run",
|
|
20
27
|
"typecheck": "tsc --noEmit",
|
|
21
28
|
"mock": "tsx mock-downstream/server.ts"
|