@~lyre/auth 0.0.9 → 0.1.1

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 CHANGED
@@ -53,6 +53,43 @@ export const handle = createAuthHandle({
53
53
  `@sveltejs/kit` is an **optional peer dependency** — only required if you import
54
54
  the `/sveltekit` entry.
55
55
 
56
+ ### `@~lyre/auth/nest` — Nest / Fastify adapter
57
+
58
+ For services on Nest + Fastify. Adds **no dependencies** — nothing here imports
59
+ `@nestjs/*` or `fastify`; you supply the guard class and DI wiring, the SDK
60
+ supplies the Accounts logic. Works from a plain Fastify app too: any request
61
+ whose `headers` is a plain object satisfies the input type.
62
+
63
+ ```ts
64
+ import { createServiceKeyResolver, createUserSessionResolver, hasScopes }
65
+ from '@~lyre/auth/nest';
66
+
67
+ const keys = createServiceKeyResolver({ config: { baseUrl: ACCOUNTS_BASE_URL } });
68
+ const sessions = createUserSessionResolver({ config: { baseUrl: ACCOUNTS_BASE_URL } });
69
+
70
+ // In a Nest guard (req is the Fastify request):
71
+ const key = await keys.resolve(req);
72
+ if (key.status === 'invalid') throw new UnauthorizedException(); // present but bad
73
+ if (key.status === 'ok') {
74
+ if (!hasScopes(key.context, ['my-app:things:read'])) throw new ForbiddenException();
75
+ // key.context.accessibleApps tells you which apps this key may act on
76
+ }
77
+
78
+ // A user session bearer resolves to who + which app, from the token alone:
79
+ const user = await sessions.resolve(req);
80
+ // → { userId, sessionId, appId, tenantId, email, name } | null
81
+ ```
82
+
83
+ Semantics match `createServiceKeyHandle`: **absent key → `absent`** (fall through to
84
+ other auth), **present-but-invalid → `invalid`** (reject rather than silently
85
+ continue). Introspection is cached by `sha256` of the credential — negative
86
+ results too, so a bad key can't hammer Accounts. Neither resolver throws: a
87
+ transport failure reads as "not authenticated", so a flaky Accounts cannot 500
88
+ the consuming service.
89
+
90
+ `resolve()` needs `GET /api/auth/me` to return `session.appId` (Accounts ≥ the
91
+ release that added it); older deployments yield `appId: null`.
92
+
56
93
  ## Publishing
57
94
 
58
95
  See [PUBLISHING.md](PUBLISHING.md). Published to npm via GitHub Actions Trusted
@@ -308,6 +308,11 @@ function verifyWebhookSignature(rawBody, signature, secret) {
308
308
  if (a.length !== b.length) return false;
309
309
  return timingSafeEqual(a, b);
310
310
  }
311
+ function normalizeAccountsWebhook(payload) {
312
+ const body = payload && typeof payload === "object" ? payload : {};
313
+ const type = String(body.event ?? body.type ?? "unknown");
314
+ return { ...body, type, data: body.data };
315
+ }
311
316
  function scopeSatisfied(granted, required) {
312
317
  if (!granted || granted.length === 0) return false;
313
318
  return granted.includes(required);
@@ -328,5 +333,6 @@ export {
328
333
  introspectServiceKey,
329
334
  logoutFromAccounts,
330
335
  verifyWebhookSignature,
336
+ normalizeAccountsWebhook,
331
337
  scopeSatisfied
332
338
  };
package/dist/index.d.ts CHANGED
@@ -174,8 +174,18 @@ declare function logoutFromAccounts(input: {
174
174
  declare function verifyWebhookSignature(rawBody: string, signature: string | null | undefined, secret: string): boolean;
175
175
  type AccountsWebhookEvent = {
176
176
  type: string;
177
+ data?: unknown;
177
178
  [key: string]: unknown;
178
179
  };
180
+ /**
181
+ * Normalize an Accounts webhook body into a stable `AccountsWebhookEvent`.
182
+ *
183
+ * The Accounts webhook worker sends `{ event, timestamp, data }` (the event NAME under `event`,
184
+ * the payload under `data`). Older/other senders may put the name under `type`. This collapses both
185
+ * so a consumer always reads `event.type` and `event.data`, and keeps the raw fields alongside.
186
+ * Framework-agnostic so both the SvelteKit handle and the Nest helper share one shape.
187
+ */
188
+ declare function normalizeAccountsWebhook(payload: unknown): AccountsWebhookEvent;
179
189
  declare function scopeSatisfied(granted: string[] | undefined, required: string): boolean;
180
190
 
181
- export { type AccountsClientConfig, type AccountsIdentity, type AccountsTokenResponse, type AccountsWebhookEvent, type AuthenticatedPrincipal, type AuthorizationState, type CookieOptions, type LocalUser, type PermissionSet, type PlatformSession, type ServiceKeyApp, type ServiceKeyContext, type SyncAccountsUser, type SyncAccountsUserResult, type TenantAccessContext, type TenantContext, type TenantMembership, beginAccountsLoginRedirect, clearPlatformSessionCookie, createAccountsClientConfig, createPlatformAuth, exchangeAuthorizationCode, handleAccountsCallback, identityPassthroughSync, introspectServiceKey, logoutFromAccounts, readPlatformSessionCookie, resolveActiveTenant, scopeSatisfied, serviceKeyFromRequest, syncAccountsUser, verifyWebhookSignature };
191
+ export { type AccountsClientConfig, type AccountsIdentity, type AccountsTokenResponse, type AccountsWebhookEvent, type AuthenticatedPrincipal, type AuthorizationState, type CookieOptions, type LocalUser, type PermissionSet, type PlatformSession, type ServiceKeyApp, type ServiceKeyContext, type SyncAccountsUser, type SyncAccountsUserResult, type TenantAccessContext, type TenantContext, type TenantMembership, beginAccountsLoginRedirect, clearPlatformSessionCookie, createAccountsClientConfig, createPlatformAuth, exchangeAuthorizationCode, handleAccountsCallback, identityPassthroughSync, introspectServiceKey, logoutFromAccounts, normalizeAccountsWebhook, readPlatformSessionCookie, resolveActiveTenant, scopeSatisfied, serviceKeyFromRequest, syncAccountsUser, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -8,13 +8,14 @@ import {
8
8
  identityPassthroughSync,
9
9
  introspectServiceKey,
10
10
  logoutFromAccounts,
11
+ normalizeAccountsWebhook,
11
12
  readPlatformSessionCookie,
12
13
  resolveActiveTenant,
13
14
  scopeSatisfied,
14
15
  serviceKeyFromRequest,
15
16
  syncAccountsUser,
16
17
  verifyWebhookSignature
17
- } from "./chunk-QIAL6IDV.js";
18
+ } from "./chunk-TQNQLX3C.js";
18
19
  export {
19
20
  beginAccountsLoginRedirect,
20
21
  clearPlatformSessionCookie,
@@ -25,6 +26,7 @@ export {
25
26
  identityPassthroughSync,
26
27
  introspectServiceKey,
27
28
  logoutFromAccounts,
29
+ normalizeAccountsWebhook,
28
30
  readPlatformSessionCookie,
29
31
  resolveActiveTenant,
30
32
  scopeSatisfied,
package/dist/nest.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { AccountsWebhookEvent, ServiceKeyContext, AccountsClientConfig } from './index.js';
2
+ export { normalizeAccountsWebhook } from './index.js';
3
+
4
+ /**
5
+ * The minimum a request must expose. Fastify's `request` satisfies this as-is
6
+ * (`headers` is a plain object), as does Node's IncomingMessage.
7
+ */
8
+ type HeaderCarrier = {
9
+ headers: Record<string, string | string[] | undefined>;
10
+ };
11
+ /**
12
+ * Adapt a header-bearing request to the web `Request` that the core helpers expect.
13
+ * Only headers are needed — `serviceKeyFromRequest` reads `x-api-key` / `authorization`.
14
+ */
15
+ declare function toWebRequest(carrier: HeaderCarrier): Request;
16
+ /** Extract an `sk_` service key from a Fastify/Node request, or null when absent. */
17
+ declare function serviceKeyFromHeaders(carrier: HeaderCarrier): string | null;
18
+ type ServiceKeyResolverOptions = {
19
+ config: Pick<AccountsClientConfig, 'baseUrl'>;
20
+ fetchImpl?: typeof fetch;
21
+ /** Introspection cache TTL in ms. Default 60_000; 0 disables caching. */
22
+ cacheTtlMs?: number;
23
+ };
24
+ type ServiceKeyResolution = {
25
+ status: 'absent';
26
+ } | {
27
+ status: 'invalid';
28
+ } | {
29
+ status: 'ok';
30
+ context: ServiceKeyContext;
31
+ };
32
+ declare function createServiceKeyResolver(opts: ServiceKeyResolverOptions): {
33
+ resolve: (carrier: HeaderCarrier) => Promise<ServiceKeyResolution>;
34
+ invalidate: (key: string) => void;
35
+ };
36
+ type UserSessionContext = {
37
+ userId: string;
38
+ email?: string;
39
+ name?: string;
40
+ sessionId: string;
41
+ /** The app the session was issued for. Null for sessions created outside an app context. */
42
+ appId: string | null;
43
+ tenantId: string | null;
44
+ expiresAt?: string;
45
+ };
46
+ type UserSessionResolverOptions = {
47
+ config: Pick<AccountsClientConfig, 'baseUrl'>;
48
+ fetchImpl?: typeof fetch;
49
+ /** Cache TTL in ms. Default 60_000; 0 disables caching. */
50
+ cacheTtlMs?: number;
51
+ };
52
+ /** A session bearer is any Authorization: Bearer token that is NOT an `sk_` service key. */
53
+ declare function sessionTokenFromHeaders(carrier: HeaderCarrier): string | null;
54
+ declare function createUserSessionResolver(opts: UserSessionResolverOptions): {
55
+ resolve: (carrier: HeaderCarrier) => Promise<UserSessionContext | null>;
56
+ invalidate: (token: string) => void;
57
+ };
58
+ /**
59
+ * Whether a resolved key grants every required scope. Uses the core's `scopeSatisfied`
60
+ * (flat exact match, no wildcards) so behaviour matches Accounts' own enforcement.
61
+ */
62
+ declare function hasScopes(ctx: ServiceKeyContext, required: readonly string[]): boolean;
63
+ /** Whether a resolved key may act on an app, matched by Accounts app id OR slug. */
64
+ declare function canAccessApp(ctx: ServiceKeyContext, appIdOrSlug: string): boolean;
65
+ type AccountsWebhookResult = {
66
+ ok: true;
67
+ event: AccountsWebhookEvent;
68
+ } | {
69
+ ok: false;
70
+ reason: 'bad_signature' | 'bad_json';
71
+ };
72
+ /** Header the Accounts webhook worker sends (HMAC-SHA256 hex of the raw body). */
73
+ declare const ACCOUNTS_WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature";
74
+ /**
75
+ * Verify an Accounts webhook and parse it into a normalized `{ type, data, ... }` event.
76
+ *
77
+ * Pass the RAW request body (a Nest/Fastify controller must read the raw body, not a re-serialized
78
+ * object — the signature is over the exact bytes), the signature header value
79
+ * (`request.headers['x-webhook-signature']`), and the shared secret. Returns `{ok:false}` on a bad
80
+ * signature or unparseable JSON so the controller can 401/400; `{ok:true, event}` otherwise. The
81
+ * controller then reacts to `event.type` / `event.data` and returns 200 (so Accounts doesn't retry).
82
+ */
83
+ declare function verifyAndParseAccountsWebhook(rawBody: string, signatureHeaderValue: string | string[] | undefined, secret: string): AccountsWebhookResult;
84
+
85
+ export { ACCOUNTS_WEBHOOK_SIGNATURE_HEADER, AccountsClientConfig, AccountsWebhookEvent, type AccountsWebhookResult, type HeaderCarrier, ServiceKeyContext, type ServiceKeyResolution, type ServiceKeyResolverOptions, type UserSessionContext, type UserSessionResolverOptions, canAccessApp, createServiceKeyResolver, createUserSessionResolver, hasScopes, serviceKeyFromHeaders, sessionTokenFromHeaders, toWebRequest, verifyAndParseAccountsWebhook };
package/dist/nest.js ADDED
@@ -0,0 +1,134 @@
1
+ import {
2
+ introspectServiceKey,
3
+ normalizeAccountsWebhook,
4
+ scopeSatisfied,
5
+ serviceKeyFromRequest,
6
+ verifyWebhookSignature
7
+ } from "./chunk-TQNQLX3C.js";
8
+
9
+ // src/nest.ts
10
+ import { createHash } from "crypto";
11
+ function headerValue(carrier, name) {
12
+ const v = carrier.headers[name] ?? carrier.headers[name.toLowerCase()];
13
+ return Array.isArray(v) ? v[0] : v;
14
+ }
15
+ function toWebRequest(carrier) {
16
+ const headers = new Headers();
17
+ for (const [k, v] of Object.entries(carrier.headers)) {
18
+ if (v === void 0) continue;
19
+ if (Array.isArray(v)) {
20
+ for (const item of v) headers.append(k, item);
21
+ } else {
22
+ headers.set(k, v);
23
+ }
24
+ }
25
+ return new Request("http://localhost/", { headers });
26
+ }
27
+ function serviceKeyFromHeaders(carrier) {
28
+ return serviceKeyFromRequest(toWebRequest(carrier));
29
+ }
30
+ function createServiceKeyResolver(opts) {
31
+ const ttl = opts.cacheTtlMs ?? 6e4;
32
+ const cache = /* @__PURE__ */ new Map();
33
+ async function resolve(carrier) {
34
+ const key = serviceKeyFromHeaders(carrier);
35
+ if (!key) return { status: "absent" };
36
+ const hash = createHash("sha256").update(key).digest("hex");
37
+ const now = Date.now();
38
+ let ctx;
39
+ const cached = ttl > 0 ? cache.get(hash) : void 0;
40
+ if (cached && cached.expires > now) {
41
+ ctx = cached.value;
42
+ } else {
43
+ ctx = await introspectServiceKey({ config: opts.config, key, fetchImpl: opts.fetchImpl });
44
+ if (ttl > 0) cache.set(hash, { value: ctx, expires: now + ttl });
45
+ }
46
+ return ctx ? { status: "ok", context: ctx } : { status: "invalid" };
47
+ }
48
+ function invalidate(key) {
49
+ cache.delete(createHash("sha256").update(key).digest("hex"));
50
+ }
51
+ return { resolve, invalidate };
52
+ }
53
+ function sessionTokenFromHeaders(carrier) {
54
+ const auth = headerValue(carrier, "authorization");
55
+ if (!auth || !auth.startsWith("Bearer ")) return null;
56
+ const token = auth.slice(7).trim();
57
+ if (!token || token.startsWith("sk_")) return null;
58
+ return token;
59
+ }
60
+ function createUserSessionResolver(opts) {
61
+ const ttl = opts.cacheTtlMs ?? 6e4;
62
+ const cache = /* @__PURE__ */ new Map();
63
+ const fetchImpl = opts.fetchImpl ?? fetch;
64
+ async function introspect(token) {
65
+ const base = opts.config.baseUrl?.replace(/\/$/, "");
66
+ if (!base || !token) return null;
67
+ try {
68
+ const res = await fetchImpl(new URL("/api/auth/me", base), {
69
+ headers: { authorization: `Bearer ${token}`, accept: "application/json" }
70
+ });
71
+ if (!res.ok) return null;
72
+ const data = await res.json();
73
+ const userId = data.user?.id;
74
+ const sessionId = data.session?.id;
75
+ if (!userId || !sessionId) return null;
76
+ return {
77
+ userId,
78
+ email: data.user?.emailAddress ?? data.user?.email,
79
+ name: data.user?.name,
80
+ sessionId,
81
+ appId: data.session?.appId ?? null,
82
+ tenantId: data.session?.tenantId ?? null,
83
+ expiresAt: data.session?.expiresAt
84
+ };
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ async function resolve(carrier) {
90
+ const token = sessionTokenFromHeaders(carrier);
91
+ if (!token) return null;
92
+ const hash = createHash("sha256").update(token).digest("hex");
93
+ const now = Date.now();
94
+ const cached = ttl > 0 ? cache.get(hash) : void 0;
95
+ if (cached && cached.expires > now) return cached.value;
96
+ const ctx = await introspect(token);
97
+ if (ttl > 0) cache.set(hash, { value: ctx, expires: now + ttl });
98
+ return ctx;
99
+ }
100
+ function invalidate(token) {
101
+ cache.delete(createHash("sha256").update(token).digest("hex"));
102
+ }
103
+ return { resolve, invalidate };
104
+ }
105
+ function hasScopes(ctx, required) {
106
+ return required.every((scope) => scopeSatisfied(ctx.scopes, scope));
107
+ }
108
+ function canAccessApp(ctx, appIdOrSlug) {
109
+ return ctx.accessibleApps.some((a) => a.id === appIdOrSlug || a.slug === appIdOrSlug);
110
+ }
111
+ var ACCOUNTS_WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature";
112
+ function verifyAndParseAccountsWebhook(rawBody, signatureHeaderValue, secret) {
113
+ const signature = Array.isArray(signatureHeaderValue) ? signatureHeaderValue[0] : signatureHeaderValue;
114
+ if (!verifyWebhookSignature(rawBody, signature ?? null, secret)) {
115
+ return { ok: false, reason: "bad_signature" };
116
+ }
117
+ try {
118
+ return { ok: true, event: normalizeAccountsWebhook(JSON.parse(rawBody)) };
119
+ } catch {
120
+ return { ok: false, reason: "bad_json" };
121
+ }
122
+ }
123
+ export {
124
+ ACCOUNTS_WEBHOOK_SIGNATURE_HEADER,
125
+ canAccessApp,
126
+ createServiceKeyResolver,
127
+ createUserSessionResolver,
128
+ hasScopes,
129
+ normalizeAccountsWebhook,
130
+ serviceKeyFromHeaders,
131
+ sessionTokenFromHeaders,
132
+ toWebRequest,
133
+ verifyAndParseAccountsWebhook
134
+ };
@@ -55,7 +55,10 @@ type AccountsWebhookOptions = {
55
55
  secret: string;
56
56
  /** Route to receive webhooks on. Default '/api/auth/accounts/webhook'. */
57
57
  path?: string;
58
- /** Header carrying the signature. Default 'x-accounts-signature'. */
58
+ /**
59
+ * Header carrying the signature. Default 'x-webhook-signature' — the header the Accounts
60
+ * webhook worker actually sends (HMAC-SHA256 hex of the raw body).
61
+ */
59
62
  signatureHeader?: string;
60
63
  /** React to a verified event. Throwing yields a 500 (Accounts may retry). */
61
64
  onEvent: (event: AccountsWebhookEvent, raw: string, request: RequestEvent) => void | Promise<void>;
package/dist/sveltekit.js CHANGED
@@ -5,10 +5,11 @@ import {
5
5
  identityPassthroughSync,
6
6
  introspectServiceKey,
7
7
  logoutFromAccounts,
8
+ normalizeAccountsWebhook,
8
9
  readPlatformSessionCookie,
9
10
  serviceKeyFromRequest,
10
11
  verifyWebhookSignature
11
- } from "./chunk-QIAL6IDV.js";
12
+ } from "./chunk-TQNQLX3C.js";
12
13
 
13
14
  // src/sveltekit.ts
14
15
  import { createHash } from "crypto";
@@ -113,21 +114,21 @@ function jsonResponse(status, body) {
113
114
  }
114
115
  function createAccountsWebhookHandle(opts) {
115
116
  const path = opts.path ?? "/api/auth/accounts/webhook";
116
- const sigHeader = opts.signatureHeader ?? "x-accounts-signature";
117
+ const sigHeader = opts.signatureHeader ?? "x-webhook-signature";
117
118
  return async ({ event, resolve }) => {
118
119
  if (event.url.pathname !== path || event.request.method !== "POST") return resolve(event);
119
120
  const raw = await event.request.text();
120
121
  if (!verifyWebhookSignature(raw, event.request.headers.get(sigHeader), opts.secret)) {
121
122
  return jsonResponse(401, { ok: false, error: "Invalid webhook signature." });
122
123
  }
123
- let payload;
124
+ let parsed;
124
125
  try {
125
- payload = JSON.parse(raw);
126
+ parsed = normalizeAccountsWebhook(JSON.parse(raw));
126
127
  } catch {
127
128
  return jsonResponse(400, { ok: false, error: "Invalid JSON body." });
128
129
  }
129
130
  try {
130
- await opts.onEvent({ ...payload, type: String(payload.type ?? "unknown") }, raw, event);
131
+ await opts.onEvent(parsed, raw, event);
131
132
  } catch {
132
133
  return jsonResponse(500, { ok: false, error: "Webhook handler failed." });
133
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@~lyre/auth",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "description": "Shared Axis Accounts auth SDK — framework-agnostic session/identity core (HMAC-signed cookies, accounts login/callback/logout) plus an optional turnkey SvelteKit adapter.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -33,7 +33,12 @@
33
33
  "./sveltekit": {
34
34
  "types": "./dist/sveltekit.d.ts",
35
35
  "import": "./dist/sveltekit.js"
36
- }
36
+ },
37
+ "./nest": {
38
+ "types": "./dist/nest.d.ts",
39
+ "import": "./dist/nest.js"
40
+ },
41
+ "./package.json": "./package.json"
37
42
  },
38
43
  "files": [
39
44
  "dist",
@@ -41,7 +46,7 @@
41
46
  "LICENSE"
42
47
  ],
43
48
  "scripts": {
44
- "build": "tsup src/index.ts src/sveltekit.ts --format esm --dts --clean",
49
+ "build": "tsup src/index.ts src/sveltekit.ts src/nest.ts --format esm --dts --clean",
45
50
  "check": "tsc --noEmit"
46
51
  },
47
52
  "peerDependencies": {