@relyt/auth 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +217 -2
- package/dist/claims.d.ts +30 -0
- package/dist/claims.js +27 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +243 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +5 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +42 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/issuer.d.ts +7 -0
- package/dist/issuer.js +37 -0
- package/dist/jwt-payload.d.ts +2 -0
- package/dist/jwt-payload.js +21 -0
- package/dist/next.d.ts +47 -0
- package/dist/next.js +205 -0
- package/dist/options.d.ts +2 -0
- package/dist/options.js +10 -0
- package/dist/social.d.ts +54 -0
- package/dist/social.js +241 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +128 -0
- package/package.json +68 -4
package/dist/social.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Cross-site social sign-in. Web APIs only.
|
|
2
|
+
// Handoff codes, PKCE verifiers and session tokens never reach errors, events or logs.
|
|
3
|
+
import { retryAfterMs } from "./client.js";
|
|
4
|
+
import { RelytAuthError } from "./errors.js";
|
|
5
|
+
import { normalizeIssuer } from "./issuer.js";
|
|
6
|
+
import { num } from "./options.js";
|
|
7
|
+
/** sessionStorage key: one pending sign-in record `{v, mode, base, t}` (single use, removed when read by complete). */
|
|
8
|
+
export const SOCIAL_PKCE_STORAGE_KEY = "relyt.auth.social.pkce";
|
|
9
|
+
const CODE_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
10
|
+
const OAUTH_ERROR_RE = /^[a-z0-9_]{1,64}$/;
|
|
11
|
+
const MODES = new Set(["fragment", "query"]);
|
|
12
|
+
const CODE_PARAM = "relyt_code";
|
|
13
|
+
const ERROR_PARAM = "error";
|
|
14
|
+
/** Parameters the callback page must remove from the address bar (code, error and its description). */
|
|
15
|
+
const STRIP_PARAMS = [CODE_PARAM, ERROR_PARAM, "error_description"];
|
|
16
|
+
const cfg = (option) => new RelytAuthError("config", { option });
|
|
17
|
+
function base64url(bytes) {
|
|
18
|
+
let s = "";
|
|
19
|
+
for (const b of bytes)
|
|
20
|
+
s += String.fromCharCode(b);
|
|
21
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
}
|
|
23
|
+
function authBase(s) {
|
|
24
|
+
try {
|
|
25
|
+
return normalizeIssuer(s);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
throw cfg("authUrl");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const optStr = (v, option) => {
|
|
32
|
+
if (v === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (typeof v !== "string" || v === "")
|
|
35
|
+
throw cfg(option);
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
/** Validates options, generates verifier and S256 challenge, builds the start URL; touches no storage and does not navigate. */
|
|
39
|
+
async function buildStart(o) {
|
|
40
|
+
if (typeof o !== "object" || o === null)
|
|
41
|
+
throw cfg("authUrl");
|
|
42
|
+
const base = authBase(o.authUrl);
|
|
43
|
+
const provider = optStr(o.provider, "provider");
|
|
44
|
+
const callbackURL = optStr(o.callbackURL, "callbackURL");
|
|
45
|
+
if (provider === undefined)
|
|
46
|
+
throw cfg("provider");
|
|
47
|
+
if (callbackURL === undefined)
|
|
48
|
+
throw cfg("callbackURL");
|
|
49
|
+
const errorCallbackURL = optStr(o.errorCallbackURL, "errorCallbackURL");
|
|
50
|
+
const newUserCallbackURL = optStr(o.newUserCallbackURL, "newUserCallbackURL");
|
|
51
|
+
const mode = o.responseMode ?? "fragment";
|
|
52
|
+
if (!MODES.has(mode))
|
|
53
|
+
throw cfg("responseMode");
|
|
54
|
+
const raw = new Uint8Array(32);
|
|
55
|
+
globalThis.crypto.getRandomValues(raw);
|
|
56
|
+
const codeVerifier = base64url(raw);
|
|
57
|
+
const challenge = base64url(new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))));
|
|
58
|
+
const q = new URLSearchParams();
|
|
59
|
+
q.set("provider", provider);
|
|
60
|
+
q.set("callbackURL", callbackURL);
|
|
61
|
+
if (errorCallbackURL !== undefined)
|
|
62
|
+
q.set("errorCallbackURL", errorCallbackURL);
|
|
63
|
+
if (newUserCallbackURL !== undefined)
|
|
64
|
+
q.set("newUserCallbackURL", newUserCallbackURL);
|
|
65
|
+
q.set("code_challenge", challenge);
|
|
66
|
+
q.set("code_challenge_method", "S256");
|
|
67
|
+
q.set("response_mode", mode);
|
|
68
|
+
return { url: `${base}/api/auth/relyt/social/start?${q.toString()}`, codeVerifier, base, mode };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Pure builder (works in browsers and Node): returns the start URL and the verifier; the caller keeps the verifier and navigates.
|
|
72
|
+
* Used for the server-proxied flow: the server generates it, binds the verifier to its own session, and redeems the code with exchangeHandoffCode after the callback.
|
|
73
|
+
*/
|
|
74
|
+
export async function createSocialStart(o) {
|
|
75
|
+
const { url, codeVerifier } = await buildStart(o);
|
|
76
|
+
return { url, codeVerifier };
|
|
77
|
+
}
|
|
78
|
+
/** Browser entry: build → store the pending record → navigate the top-level window to the auth host. */
|
|
79
|
+
export async function startSocialSignIn(o) {
|
|
80
|
+
const { url, codeVerifier: v, base, mode } = await buildStart(o);
|
|
81
|
+
try {
|
|
82
|
+
globalThis.sessionStorage.setItem(SOCIAL_PKCE_STORAGE_KEY, JSON.stringify({ v, mode, base, t: Date.now() }));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw cfg("sessionStorage");
|
|
86
|
+
}
|
|
87
|
+
globalThis.location.assign(url);
|
|
88
|
+
}
|
|
89
|
+
/** Lifetime of a pending record: older records are treated as missing. */
|
|
90
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
91
|
+
/** Takes and removes the pending record (single use); unreadable / invalid ⇒ null. */
|
|
92
|
+
function takePending() {
|
|
93
|
+
let raw = null;
|
|
94
|
+
try {
|
|
95
|
+
raw = globalThis.sessionStorage.getItem(SOCIAL_PKCE_STORAGE_KEY);
|
|
96
|
+
globalThis.sessionStorage.removeItem(SOCIAL_PKCE_STORAGE_KEY);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (raw === null)
|
|
102
|
+
return null;
|
|
103
|
+
try {
|
|
104
|
+
const p = JSON.parse(raw);
|
|
105
|
+
if (p && typeof p.v === "string" && CODE_RE.test(p.v) && typeof p.mode === "string" && MODES.has(p.mode) &&
|
|
106
|
+
typeof p.base === "string" && typeof p.t === "number" && Number.isFinite(p.t)) {
|
|
107
|
+
return { v: p.v, mode: p.mode, base: p.base, t: p.t };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* a corrupt record is treated as missing */
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
/** (1) read the URL (2) remove code / error from the address bar; runs before anything else, including option validation. */
|
|
116
|
+
function readAndCleanUrl() {
|
|
117
|
+
const url = new URL(globalThis.location.href);
|
|
118
|
+
const frag = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
|
119
|
+
const fromQuery = url.searchParams.getAll(CODE_PARAM);
|
|
120
|
+
const fromFrag = frag.getAll(CODE_PARAM);
|
|
121
|
+
const err = url.searchParams.get(ERROR_PARAM) ?? frag.get(ERROR_PARAM);
|
|
122
|
+
let changed = false;
|
|
123
|
+
if (STRIP_PARAMS.some((k) => url.searchParams.has(k))) {
|
|
124
|
+
for (const k of STRIP_PARAMS)
|
|
125
|
+
url.searchParams.delete(k);
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
if (STRIP_PARAMS.some((k) => frag.has(k))) {
|
|
129
|
+
for (const k of STRIP_PARAMS)
|
|
130
|
+
frag.delete(k);
|
|
131
|
+
const rest = frag.toString();
|
|
132
|
+
url.hash = rest === "" ? "" : `#${rest}`;
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
if (changed)
|
|
136
|
+
globalThis.history.replaceState(globalThis.history.state, "", url.href);
|
|
137
|
+
return { fromQuery, fromFrag, err };
|
|
138
|
+
}
|
|
139
|
+
/** The complete() call in progress: concurrent calls share one promise, reset when it settles (success or failure). */
|
|
140
|
+
let inFlight = null;
|
|
141
|
+
/**
|
|
142
|
+
* Call on the registered callback page: clean the address bar → validate options → take the verifier → exchange → deliver the session token.
|
|
143
|
+
* Re-entrant: a call while one is in progress (e.g. React StrictMode's double effect) returns the same promise, so the code is exchanged
|
|
144
|
+
* and the token delivered exactly once; options passed to the later call are ignored.
|
|
145
|
+
*/
|
|
146
|
+
export function completeSocialSignIn(o) {
|
|
147
|
+
if (inFlight !== null)
|
|
148
|
+
return inFlight;
|
|
149
|
+
const p = runComplete(o).finally(() => {
|
|
150
|
+
if (inFlight === p)
|
|
151
|
+
inFlight = null;
|
|
152
|
+
});
|
|
153
|
+
inFlight = p;
|
|
154
|
+
return p;
|
|
155
|
+
}
|
|
156
|
+
async function runComplete(o) {
|
|
157
|
+
const { fromQuery, fromFrag, err } = readAndCleanUrl();
|
|
158
|
+
if (typeof o !== "object" || o === null)
|
|
159
|
+
throw cfg("authUrl");
|
|
160
|
+
const base = authBase(o.authUrl);
|
|
161
|
+
if (typeof o.onSessionToken !== "function")
|
|
162
|
+
throw cfg("onSessionToken");
|
|
163
|
+
const timeoutMs = num(o.timeoutMs, 15_000, "timeoutMs", (n) => Number.isInteger(n) && n >= 1_000 && n <= 60_000);
|
|
164
|
+
// (3) take and remove the record (single use).
|
|
165
|
+
const pending = takePending();
|
|
166
|
+
// (4) error callback (checked before the missing-record check).
|
|
167
|
+
if (err !== null) {
|
|
168
|
+
throw new RelytAuthError("oauth_error", { retryable: false, oauthError: OAUTH_ERROR_RE.test(err) ? err : "unknown" });
|
|
169
|
+
}
|
|
170
|
+
// (5) no record started in this browser session ⇒ never exchange (defends against planted codes, i.e. login CSRF).
|
|
171
|
+
if (pending === null)
|
|
172
|
+
throw new RelytAuthError("no_pending_sign_in", { retryable: false });
|
|
173
|
+
// (5') the record must have been started for the same auth host and not be expired; otherwise treat it as missing, no separate error code.
|
|
174
|
+
if (pending.base !== base || Date.now() - pending.t > PENDING_TTL_MS) {
|
|
175
|
+
throw new RelytAuthError("no_pending_sign_in", { retryable: false });
|
|
176
|
+
}
|
|
177
|
+
// (6) take the code only from the location named by the record's mode; exactly one, well-formed.
|
|
178
|
+
const codes = pending.mode === "fragment" ? fromFrag : fromQuery;
|
|
179
|
+
const code = codes.length === 1 ? codes[0] : undefined;
|
|
180
|
+
if (code === undefined || !CODE_RE.test(code))
|
|
181
|
+
throw new RelytAuthError("missing_code", { retryable: false });
|
|
182
|
+
const { sessionToken } = await exchange(base, code, pending.v, o.fetch, timeoutMs);
|
|
183
|
+
o.client?.clear();
|
|
184
|
+
o.onSessionToken(sessionToken);
|
|
185
|
+
}
|
|
186
|
+
/** Server-side proxy (Node / Next route handler): no Origin header, exchanges directly. */
|
|
187
|
+
export async function exchangeHandoffCode(o) {
|
|
188
|
+
if (typeof o !== "object" || o === null)
|
|
189
|
+
throw cfg("authUrl");
|
|
190
|
+
const base = authBase(o.authUrl);
|
|
191
|
+
if (typeof o.code !== "string" || o.code === "")
|
|
192
|
+
throw cfg("code");
|
|
193
|
+
if (typeof o.codeVerifier !== "string" || o.codeVerifier === "")
|
|
194
|
+
throw cfg("codeVerifier");
|
|
195
|
+
const timeoutMs = num(o.timeoutMs, 15_000, "timeoutMs", (n) => Number.isInteger(n) && n >= 1_000 && n <= 60_000);
|
|
196
|
+
return exchange(base, o.code, o.codeVerifier, o.fetch, timeoutMs);
|
|
197
|
+
}
|
|
198
|
+
/** Single POST, no retry (codes are single use); status → error code mapping is documented in the README. */
|
|
199
|
+
async function exchange(base, code, codeVerifier, f, timeoutMs) {
|
|
200
|
+
const doFetch = f ?? ((input, init) => globalThis.fetch(input, init));
|
|
201
|
+
const ac = new AbortController();
|
|
202
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
203
|
+
let res;
|
|
204
|
+
try {
|
|
205
|
+
res = await doFetch(`${base}/api/auth/relyt/exchange`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/json" },
|
|
208
|
+
body: JSON.stringify({ code, code_verifier: codeVerifier }),
|
|
209
|
+
credentials: "omit",
|
|
210
|
+
signal: ac.signal,
|
|
211
|
+
});
|
|
212
|
+
await res.text().catch(() => "");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new RelytAuthError("network", { retryable: false });
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
}
|
|
220
|
+
const s = res.status;
|
|
221
|
+
if (s === 200) {
|
|
222
|
+
const t = res.headers.get("set-auth-token");
|
|
223
|
+
if (t === null || t === "")
|
|
224
|
+
throw new RelytAuthError("bad_response", { status: 200, retryable: false });
|
|
225
|
+
return { sessionToken: t };
|
|
226
|
+
}
|
|
227
|
+
if (s === 400)
|
|
228
|
+
throw new RelytAuthError("invalid_handoff_code", { status: s, retryable: false });
|
|
229
|
+
// 403 is only reachable by non-browser callers that send their own Origin: in a browser an unregistered origin fails the CORS preflight first, which surfaces as network.
|
|
230
|
+
if (s === 403)
|
|
231
|
+
throw new RelytAuthError("invalid_origin", { status: s, retryable: false });
|
|
232
|
+
if (s === 429) {
|
|
233
|
+
// Reuses the client's parser (integer seconds / IMF-fixdate, clamped to [0, 10] s, invalid ⇒ 2 s); header missing ⇒ omitted.
|
|
234
|
+
const ra = res.headers.get("retry-after");
|
|
235
|
+
const retryAfterS = ra === null ? undefined : retryAfterMs(ra) / 1000;
|
|
236
|
+
throw new RelytAuthError("rate_limited", { status: s, retryable: false, ...(retryAfterS === undefined ? {} : { retryAfterS }) });
|
|
237
|
+
}
|
|
238
|
+
if (s === 503)
|
|
239
|
+
throw new RelytAuthError("unavailable", { status: s, retryable: false });
|
|
240
|
+
throw new RelytAuthError("bad_response", { status: s, retryable: false });
|
|
241
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type RelytClaims } from "./claims.js";
|
|
2
|
+
import { RelytAuthError, type AuthEvent } from "./errors.js";
|
|
3
|
+
export interface VerifierOptions {
|
|
4
|
+
/** Expected value of both iss and aud. */
|
|
5
|
+
issuer: string;
|
|
6
|
+
/** Default `${issuer}/.well-known/jwks.json`; only changes where keys are fetched from. */
|
|
7
|
+
jwksUrl?: string;
|
|
8
|
+
/** Default 30, ≤ 60 (the JWKS keeps a retired key for only 60 s beyond the token TTL). */
|
|
9
|
+
clockToleranceS?: number;
|
|
10
|
+
/** Default 900. */
|
|
11
|
+
maxTokenAgeS?: number;
|
|
12
|
+
/** Default 30_000. */
|
|
13
|
+
cooldownMs?: number;
|
|
14
|
+
/** Default 5_000. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
onEvent?: (e: AuthEvent) => void;
|
|
17
|
+
}
|
|
18
|
+
/** Marker for a remote key set that failed while fetching keys (network / non-200 / non-JSON / timeout / invalid JWKS). */
|
|
19
|
+
export declare class JwksUnavailable extends Error {
|
|
20
|
+
constructor();
|
|
21
|
+
}
|
|
22
|
+
/** Maps errors by `err.code`; anything unknown becomes invalid_token (fail closed). The jose error is not kept (its payload contains claims). */
|
|
23
|
+
export declare function mapJoseError(err: unknown): RelytAuthError;
|
|
24
|
+
export declare function createVerifier(o: VerifierOptions): (token: string) => Promise<RelytClaims>;
|
|
25
|
+
/** Claim options are resolved on every call; remote key sets are reused per cache key. */
|
|
26
|
+
export declare function verifyToken(token: string, o: VerifierOptions): Promise<RelytClaims>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side token verification. Web APIs only (jose's webapi build).
|
|
3
|
+
* Fixed: algorithms ["EdDSA"] (not configurable), issuer = audience = the issuer given at construction (never derived from the token).
|
|
4
|
+
*/
|
|
5
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
6
|
+
import { checkClaimsShape } from "./claims.js";
|
|
7
|
+
import { RelytAuthError } from "./errors.js";
|
|
8
|
+
import { MAX_CLOCK_TOLERANCE_S, TOKEN_TTL_S } from "./constants.js";
|
|
9
|
+
import { normalizeIssuer, normalizeJwksUrl } from "./issuer.js";
|
|
10
|
+
import { num } from "./options.js";
|
|
11
|
+
const ALGORITHMS = ["EdDSA"];
|
|
12
|
+
const REQUIRED_CLAIMS = ["exp", "iat", "sub"];
|
|
13
|
+
const CACHE_MAX_AGE_MS = 300_000;
|
|
14
|
+
// jose's createRemoteJWKSet requires integer milliseconds; a non-integer throws a synchronous TypeError that would surface as invalid_token (every request 401). The cap prevents setTimeout overflow.
|
|
15
|
+
const MAX_TIMEOUT_MS = 60_000;
|
|
16
|
+
const MAX_COOLDOWN_MS = 3_600_000;
|
|
17
|
+
const JWKS_CACHE_LIMIT = 64;
|
|
18
|
+
function resolve(o) {
|
|
19
|
+
if (typeof o !== "object" || o === null)
|
|
20
|
+
throw new RelytAuthError("config", { option: "issuer" });
|
|
21
|
+
const issuer = normalizeIssuer(o.issuer);
|
|
22
|
+
const r = {
|
|
23
|
+
issuer,
|
|
24
|
+
jwksHref: normalizeJwksUrl(o.jwksUrl ?? `${issuer}/.well-known/jwks.json`),
|
|
25
|
+
clockToleranceS: num(o.clockToleranceS, 30, "clockToleranceS", (n) => n >= 0 && n <= MAX_CLOCK_TOLERANCE_S),
|
|
26
|
+
maxTokenAgeS: num(o.maxTokenAgeS, TOKEN_TTL_S, "maxTokenAgeS", (n) => n > 0),
|
|
27
|
+
cooldownMs: num(o.cooldownMs, 30_000, "cooldownMs", (n) => Number.isInteger(n) && n >= 0 && n <= MAX_COOLDOWN_MS),
|
|
28
|
+
timeoutMs: num(o.timeoutMs, 5_000, "timeoutMs", (n) => Number.isInteger(n) && n > 0 && n <= MAX_TIMEOUT_MS),
|
|
29
|
+
};
|
|
30
|
+
if (o.onEvent !== undefined)
|
|
31
|
+
r.onEvent = o.onEvent;
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
/** Marker for a remote key set that failed while fetching keys (network / non-200 / non-JSON / timeout / invalid JWKS). */
|
|
35
|
+
export class JwksUnavailable extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("jwks unavailable");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const KID_MISS = new Set(["ERR_JWKS_NO_MATCHING_KEY", "ERR_JWKS_MULTIPLE_MATCHING_KEYS"]);
|
|
41
|
+
const JWKS_DOWN = new Set(["ERR_JWKS_TIMEOUT", "ERR_JWKS_INVALID"]);
|
|
42
|
+
/** Maps errors by `err.code`; anything unknown becomes invalid_token (fail closed). The jose error is not kept (its payload contains claims). */
|
|
43
|
+
export function mapJoseError(err) {
|
|
44
|
+
if (err instanceof RelytAuthError)
|
|
45
|
+
return err;
|
|
46
|
+
if (err instanceof JwksUnavailable)
|
|
47
|
+
return new RelytAuthError("jwks_unavailable");
|
|
48
|
+
const code = typeof err === "object" && err !== null ? err.code : undefined;
|
|
49
|
+
if (typeof code === "string" && JWKS_DOWN.has(code))
|
|
50
|
+
return new RelytAuthError("jwks_unavailable");
|
|
51
|
+
return new RelytAuthError("invalid_token");
|
|
52
|
+
}
|
|
53
|
+
/** Process-wide remote JWKS cache: caches the key sets only, keyed by jwksUrl + cooldown + timeout + cacheMaxAge; LRU of 64. */
|
|
54
|
+
const jwksCache = new Map();
|
|
55
|
+
function remoteKeys(r) {
|
|
56
|
+
const key = JSON.stringify([r.jwksHref, r.cooldownMs, r.timeoutMs, CACHE_MAX_AGE_MS]);
|
|
57
|
+
const hit = jwksCache.get(key);
|
|
58
|
+
if (hit) {
|
|
59
|
+
jwksCache.delete(key);
|
|
60
|
+
jwksCache.set(key, hit);
|
|
61
|
+
return hit;
|
|
62
|
+
}
|
|
63
|
+
const remote = createRemoteJWKSet(new URL(r.jwksHref), {
|
|
64
|
+
cooldownDuration: r.cooldownMs,
|
|
65
|
+
cacheMaxAge: CACHE_MAX_AGE_MS,
|
|
66
|
+
timeoutDuration: r.timeoutMs,
|
|
67
|
+
});
|
|
68
|
+
// kid miss ⇒ rethrow as-is (invalid_token); any failure to obtain the key set ⇒ JwksUnavailable (the middleware answers 503, not 401)
|
|
69
|
+
const getKey = async (header, token) => {
|
|
70
|
+
try {
|
|
71
|
+
return await remote(header, token);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
const code = typeof e === "object" && e !== null ? e.code : undefined;
|
|
75
|
+
if (typeof code === "string" && KID_MISS.has(code))
|
|
76
|
+
throw e;
|
|
77
|
+
if (code === "ERR_JOSE_NOT_SUPPORTED" || code === "ERR_JWK_INVALID")
|
|
78
|
+
throw e;
|
|
79
|
+
throw new JwksUnavailable();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
jwksCache.set(key, getKey);
|
|
83
|
+
if (jwksCache.size > JWKS_CACHE_LIMIT)
|
|
84
|
+
jwksCache.delete(jwksCache.keys().next().value);
|
|
85
|
+
return getKey;
|
|
86
|
+
}
|
|
87
|
+
async function verifyWith(r, token) {
|
|
88
|
+
try {
|
|
89
|
+
if (typeof token !== "string" || token === "")
|
|
90
|
+
throw new RelytAuthError("invalid_token");
|
|
91
|
+
const { payload } = await jwtVerify(token, remoteKeys(r), {
|
|
92
|
+
algorithms: ALGORITHMS,
|
|
93
|
+
issuer: r.issuer,
|
|
94
|
+
audience: r.issuer,
|
|
95
|
+
requiredClaims: REQUIRED_CLAIMS,
|
|
96
|
+
clockTolerance: r.clockToleranceS,
|
|
97
|
+
maxTokenAge: r.maxTokenAgeS,
|
|
98
|
+
});
|
|
99
|
+
// Post-check: jose's maxTokenAge only bounds the age of iat, not the issued lifetime
|
|
100
|
+
const life = payload.exp - payload.iat;
|
|
101
|
+
if (!(life > 0 && life <= r.maxTokenAgeS))
|
|
102
|
+
throw new RelytAuthError("invalid_token");
|
|
103
|
+
if (!checkClaimsShape(payload))
|
|
104
|
+
throw new RelytAuthError("invalid_claims");
|
|
105
|
+
return structuredClone(payload);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const err = mapJoseError(e);
|
|
109
|
+
emit(r, { type: "verify_failed", code: err.code, issuer: r.issuer });
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function emit(r, e) {
|
|
114
|
+
try {
|
|
115
|
+
r.onEvent?.(e);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// an observer callback must not affect the verification result
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function createVerifier(o) {
|
|
122
|
+
const r = resolve(o);
|
|
123
|
+
return (token) => verifyWith(r, token);
|
|
124
|
+
}
|
|
125
|
+
/** Claim options are resolved on every call; remote key sets are reused per cache key. */
|
|
126
|
+
export async function verifyToken(token, o) {
|
|
127
|
+
return verifyWith(resolve(o), token);
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relyt/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Relyt managed-auth SDK: client token cache and server-side JWT verification (EdDSA, remote JWKS), plus a Next.js middleware entry.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"relyt",
|
|
7
|
+
"auth",
|
|
8
|
+
"jwt",
|
|
9
|
+
"jwks",
|
|
10
|
+
"eddsa",
|
|
11
|
+
"nextjs",
|
|
12
|
+
"middleware"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Relyt",
|
|
16
|
+
"homepage": "https://github.com/relytcloud/relyt-auth-js#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/relytcloud/relyt-auth-js.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/relytcloud/relyt-auth-js/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./next": {
|
|
31
|
+
"types": "./dist/next.d.ts",
|
|
32
|
+
"default": "./dist/next.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"packageManager": "pnpm@10.18.1",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"test": "tsc -p tsconfig.build.json && vitest run",
|
|
48
|
+
"typecheck": "tsc --noEmit -p .",
|
|
49
|
+
"prepublishOnly": "pnpm run build && pnpm run test"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"jose": "6.2.13"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"next": "^14.2.35 || >=15.5.28 <16"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"next": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "24.19.2",
|
|
64
|
+
"next": "15.5.28",
|
|
65
|
+
"react": "19.2.9",
|
|
66
|
+
"react-dom": "19.2.9",
|
|
67
|
+
"typescript": "5.9.3",
|
|
68
|
+
"vitest": "5.0.5"
|
|
69
|
+
}
|
|
70
|
+
}
|