@dialtribe/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/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @dialtribe/auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Relying-party SDK for **Dialtribe Identity** (OIDC SSO). One audited copy of the
4
+ auth glue every portfolio client (80.at, Tugbeat, Bobl, ...) would otherwise
5
+ hand-roll: sign-in, global sign-out, guest-work resume, and back-channel logout.
6
+
7
+ Two entry points, so server deps (`jose`) never land in a client bundle:
8
+
9
+ - `@dialtribe/auth/client` - browser helpers (zero server deps)
10
+ - `@dialtribe/auth/server` - route handler + better-auth config (needs `jose`)
11
+
12
+ ```
13
+ yarn add @dialtribe/auth
14
+ ```
15
+
16
+ ## Client
17
+
18
+ ```ts
19
+ import {
20
+ startDialtribeSignIn,
21
+ signOutEverywhere,
22
+ signInWithResume,
23
+ consumeResumeState,
24
+ } from "@dialtribe/auth/client";
25
+
26
+ // Start sign-in (e.g. from a "Sign in" button):
27
+ await startDialtribeSignIn({ callbackURL: "/dashboard", theme: "dark" });
28
+
29
+ // Global sign-out (clears the local session + ends the Dialtribe session):
30
+ await signOutEverywhere({ clientId: "80at", postLogout: "/signin" });
31
+
32
+ // Guest fills a form, then signs in to continue, without losing input:
33
+ await signInWithResume({ appState: formValues, returnTo: "/", theme: "dark" });
34
+ // ...and on the return page:
35
+ useEffect(() => {
36
+ const resumed = consumeResumeState<FormValues>();
37
+ if (resumed) setFormValues(resumed); // PREFILL only; re-validate server-side
38
+ }, []);
39
+ ```
40
+
41
+ ## Server
42
+
43
+ ```ts
44
+ // better-auth config
45
+ import { genericOAuth } from "better-auth/plugins";
46
+ import { dialtribeProvider, recommendedAccountOptions } from "@dialtribe/auth/server";
47
+
48
+ betterAuth({
49
+ account: { ...recommendedAccountOptions }, // cookie state strategy (avoids state_mismatch)
50
+ plugins: [
51
+ genericOAuth({
52
+ config: [
53
+ dialtribeProvider({
54
+ clientId: process.env.DIALTRIBE_OIDC_CLIENT_ID!,
55
+ clientSecret: process.env.DIALTRIBE_OIDC_CLIENT_SECRET!,
56
+ }), // pkce: true baked in
57
+ ],
58
+ }),
59
+ ],
60
+ });
61
+ ```
62
+
63
+ ```ts
64
+ // app/api/auth/backchannel-logout/route.ts
65
+ import { createBackchannelLogoutHandler } from "@dialtribe/auth/server";
66
+ import { prisma } from "@/lib/prisma";
67
+
68
+ export const POST = createBackchannelLogoutHandler({
69
+ clientId: process.env.DIALTRIBE_OIDC_CLIENT_ID!,
70
+ // The one app-specific bit: map the Dialtribe sub to local users and drop
71
+ // their sessions. Verification (JWKS, issuer, audience, typ, events, nonce)
72
+ // is handled by the SDK.
73
+ deleteSessionsForSub: async (sub) => {
74
+ const accounts = await prisma.account.findMany({
75
+ where: { providerId: "dialtribe", accountId: sub },
76
+ select: { userId: true },
77
+ });
78
+ const userIds = [...new Set(accounts.map((a) => a.userId))];
79
+ if (userIds.length) await prisma.session.deleteMany({ where: { userId: { in: userIds } } });
80
+ },
81
+ });
82
+ ```
83
+
84
+ ## Configuration
85
+
86
+ All helpers default to the Dialtribe prod issuer/discovery and better-auth's
87
+ `/api/auth` base path. Override per call (`discoveryUrl`, `issuer`,
88
+ `authBasePath`) for staging or a custom mount.
89
+
90
+ Full integration guide: `docs/DIALTRIBE_IDENTITY_SSO.md` in the dialtribe repo.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @dialtribe/auth/client - browser helpers for a Dialtribe Identity relying party.
3
+ *
4
+ * Zero server dependencies (no jose, no better-auth imports) so it never drags
5
+ * Node code into a client bundle. Everything here runs in the browser and talks
6
+ * to the consuming app's own better-auth routes (relative paths) plus Dialtribe's
7
+ * public endpoints.
8
+ */
9
+ export type Theme = "light" | "dark";
10
+ export interface StartSignInOptions {
11
+ /** Where to land after the whole flow completes (better-auth carries this). */
12
+ callbackURL: string;
13
+ /** OIDC provider id configured in the client's genericOAuth (default "dialtribe"). */
14
+ providerId?: string;
15
+ /** Force the Dialtribe sign-in screen theme. */
16
+ theme?: Theme;
17
+ /** Override the client's better-auth base path (default "/api/auth"). */
18
+ authBasePath?: string;
19
+ }
20
+ /**
21
+ * Start the Dialtribe (OIDC) sign-in: ask better-auth for the authorize URL,
22
+ * optionally force a theme, then navigate. The flow needs a top-level
23
+ * navigation (not a background fetch) so the browser gets the real redirect.
24
+ */
25
+ export declare function startDialtribeSignIn(opts: StartSignInOptions): Promise<void>;
26
+ export interface SignOutOptions {
27
+ /** This app's Dialtribe client_id. */
28
+ clientId: string;
29
+ /** Registered post-logout return URL (absolute, or relative to this origin). */
30
+ postLogout: string;
31
+ /** Dialtribe issuer origin (default https://id.dialtribe.com). */
32
+ issuer?: string;
33
+ /** Override the client's better-auth base path (default "/api/auth"). */
34
+ authBasePath?: string;
35
+ }
36
+ /**
37
+ * Sign out of this app AND Dialtribe, so there is no silent re-login. Clears the
38
+ * local session first (the Content-Type header is required; better-auth returns
39
+ * 415 and clears nothing without it), then redirects through Dialtribe's
40
+ * end-session endpoint back to `postLogout` (which must be a registered redirect
41
+ * URL for this client).
42
+ */
43
+ export declare function signOutEverywhere(opts: SignOutOptions): Promise<void>;
44
+ /**
45
+ * Stash `appState` under a fresh nonce and return a relative callbackURL (your
46
+ * return page + the nonce) to hand to the sign-in call.
47
+ */
48
+ export declare function stashResumeState(appState: unknown, returnTo: string): string;
49
+ /**
50
+ * On the return page, pull back the stashed appState (and clear it + strip the
51
+ * nonce from the URL so a refresh does not look like a resume). Returns null if
52
+ * there is nothing to resume.
53
+ */
54
+ export declare function consumeResumeState<T = unknown>(): T | null;
55
+ export interface SignInWithResumeOptions {
56
+ /** Arbitrary JSON-serializable state to restore after login. */
57
+ appState: unknown;
58
+ /** Page to land on after login (the one that calls consumeResumeState). */
59
+ returnTo: string;
60
+ providerId?: string;
61
+ theme?: Theme;
62
+ authBasePath?: string;
63
+ }
64
+ /** Stash the form state, then start sign-in returning to `returnTo` with the nonce. */
65
+ export declare function signInWithResume(opts: SignInWithResumeOptions): Promise<void>;
66
+ /**
67
+ * Minimal shape of a Centrifuge publication source (a Subscription, or the
68
+ * client itself for server-side subscriptions). Structural so we don't depend
69
+ * on the `centrifuge` package.
70
+ */
71
+ export interface PublicationSource {
72
+ on(event: "publication", handler: (ctx: {
73
+ data?: unknown;
74
+ }) => void): unknown;
75
+ }
76
+ /**
77
+ * React to Dialtribe's instant "sign out everywhere" signal. Wire this to the
78
+ * identity channel (`dt_id:<sub>`) your connection is subscribed to (attached
79
+ * server-side when the product passes `dialtribeSub` to the realtime token
80
+ * endpoint). On `session.revoked`, run `onRevoked` (clear the local session).
81
+ *
82
+ * Reuses the app's existing Centrifuge connection; it does not open a new one.
83
+ *
84
+ * subscribeSessionRevocation(centrifuge, () => signOutLocal());
85
+ */
86
+ export declare function subscribeSessionRevocation(source: PublicationSource, onRevoked: () => void): void;
package/dist/client.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @dialtribe/auth/client - browser helpers for a Dialtribe Identity relying party.
3
+ *
4
+ * Zero server dependencies (no jose, no better-auth imports) so it never drags
5
+ * Node code into a client bundle. Everything here runs in the browser and talks
6
+ * to the consuming app's own better-auth routes (relative paths) plus Dialtribe's
7
+ * public endpoints.
8
+ */
9
+ const DEFAULT_ISSUER = "https://id.dialtribe.com";
10
+ const DEFAULT_AUTH_BASE = "/api/auth"; // better-auth's default mount path on the client app
11
+ /**
12
+ * Start the Dialtribe (OIDC) sign-in: ask better-auth for the authorize URL,
13
+ * optionally force a theme, then navigate. The flow needs a top-level
14
+ * navigation (not a background fetch) so the browser gets the real redirect.
15
+ */
16
+ export async function startDialtribeSignIn(opts) {
17
+ const base = opts.authBasePath ?? DEFAULT_AUTH_BASE;
18
+ const res = await fetch(`${base}/sign-in/oauth2`, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify({ providerId: opts.providerId ?? "dialtribe", callbackURL: opts.callbackURL }),
22
+ });
23
+ const data = (await res.json().catch(() => ({})));
24
+ if (!res.ok || !data.url)
25
+ throw new Error(data.message || "Sign-in failed");
26
+ const url = new URL(data.url);
27
+ if (opts.theme)
28
+ url.searchParams.set("theme", opts.theme);
29
+ window.location.href = url.toString();
30
+ }
31
+ /**
32
+ * Sign out of this app AND Dialtribe, so there is no silent re-login. Clears the
33
+ * local session first (the Content-Type header is required; better-auth returns
34
+ * 415 and clears nothing without it), then redirects through Dialtribe's
35
+ * end-session endpoint back to `postLogout` (which must be a registered redirect
36
+ * URL for this client).
37
+ */
38
+ export async function signOutEverywhere(opts) {
39
+ const base = opts.authBasePath ?? DEFAULT_AUTH_BASE;
40
+ await fetch(`${base}/sign-out`, {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: "{}",
44
+ });
45
+ const issuer = opts.issuer ?? DEFAULT_ISSUER;
46
+ const postLogout = new URL(opts.postLogout, window.location.origin).toString();
47
+ window.location.href =
48
+ `${issuer}/api/auth/oauth2/endsession?client_id=${encodeURIComponent(opts.clientId)}` +
49
+ `&post_logout_redirect_uri=${encodeURIComponent(postLogout)}`;
50
+ }
51
+ // ----------------------------------------------------------------------------
52
+ // Resume guest work across the sign-in round trip ("appState" pattern)
53
+ // ----------------------------------------------------------------------------
54
+ /**
55
+ * A guest fills out a form, then has to sign in to continue, and we must not
56
+ * lose what they typed. The flow stays in the same browser tab, and
57
+ * sessionStorage is per-origin AND per-tab, so the payload can sit in this
58
+ * origin's sessionStorage untouched while the tab is over on id.dialtribe.com
59
+ * and is still there on return. Only a short nonce round-trips, in the
60
+ * callbackURL better-auth already carries. Dialtribe never sees or stores it.
61
+ *
62
+ * SECURITY: treat anything consumeResumeState() returns as untrusted input (it
63
+ * is just browser state the user can edit). Use it to PREFILL only; the real
64
+ * action still runs your normal server-side validation under the now-signed-in
65
+ * session.
66
+ */
67
+ const KEY_PREFIX = "dt_resume:";
68
+ const RESUME_PARAM = "dt_resume";
69
+ const MAX_AGE_MS = 30 * 60 * 1000; // ephemeral: a stale stash is dropped after 30 min
70
+ /** Drop any stash older than MAX_AGE_MS (or unparseable) so they never pile up. */
71
+ function sweepExpired() {
72
+ try {
73
+ const now = Date.now();
74
+ for (let i = sessionStorage.length - 1; i >= 0; i--) {
75
+ const key = sessionStorage.key(i);
76
+ if (!key || !key.startsWith(KEY_PREFIX))
77
+ continue;
78
+ try {
79
+ const env = JSON.parse(sessionStorage.getItem(key) || "{}");
80
+ if (!env.ts || now - env.ts > MAX_AGE_MS)
81
+ sessionStorage.removeItem(key);
82
+ }
83
+ catch {
84
+ sessionStorage.removeItem(key);
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // sessionStorage unavailable (SSR / privacy mode): nothing to sweep.
90
+ }
91
+ }
92
+ /**
93
+ * Stash `appState` under a fresh nonce and return a relative callbackURL (your
94
+ * return page + the nonce) to hand to the sign-in call.
95
+ */
96
+ export function stashResumeState(appState, returnTo) {
97
+ sweepExpired();
98
+ const nonce = crypto.randomUUID();
99
+ const envelope = { appState, ts: Date.now() };
100
+ try {
101
+ sessionStorage.setItem(KEY_PREFIX + nonce, JSON.stringify(envelope));
102
+ }
103
+ catch {
104
+ // Storage full/blocked: fall back to a plain return (form just won't resume).
105
+ return returnTo;
106
+ }
107
+ const url = new URL(returnTo, window.location.origin);
108
+ url.searchParams.set(RESUME_PARAM, nonce);
109
+ return url.pathname + url.search + url.hash;
110
+ }
111
+ /**
112
+ * On the return page, pull back the stashed appState (and clear it + strip the
113
+ * nonce from the URL so a refresh does not look like a resume). Returns null if
114
+ * there is nothing to resume.
115
+ */
116
+ export function consumeResumeState() {
117
+ if (typeof window === "undefined")
118
+ return null;
119
+ const params = new URLSearchParams(window.location.search);
120
+ const nonce = params.get(RESUME_PARAM);
121
+ if (!nonce)
122
+ return null;
123
+ const raw = sessionStorage.getItem(KEY_PREFIX + nonce);
124
+ sessionStorage.removeItem(KEY_PREFIX + nonce);
125
+ params.delete(RESUME_PARAM);
126
+ const qs = params.toString();
127
+ window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash);
128
+ if (!raw)
129
+ return null;
130
+ try {
131
+ const env = JSON.parse(raw);
132
+ if (!env.ts || Date.now() - env.ts > MAX_AGE_MS)
133
+ return null;
134
+ return env.appState;
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ /** Stash the form state, then start sign-in returning to `returnTo` with the nonce. */
141
+ export async function signInWithResume(opts) {
142
+ const callbackURL = stashResumeState(opts.appState, opts.returnTo);
143
+ await startDialtribeSignIn({
144
+ callbackURL,
145
+ providerId: opts.providerId,
146
+ theme: opts.theme,
147
+ authBasePath: opts.authBasePath,
148
+ });
149
+ }
150
+ /**
151
+ * React to Dialtribe's instant "sign out everywhere" signal. Wire this to the
152
+ * identity channel (`dt_id:<sub>`) your connection is subscribed to (attached
153
+ * server-side when the product passes `dialtribeSub` to the realtime token
154
+ * endpoint). On `session.revoked`, run `onRevoked` (clear the local session).
155
+ *
156
+ * Reuses the app's existing Centrifuge connection; it does not open a new one.
157
+ *
158
+ * subscribeSessionRevocation(centrifuge, () => signOutLocal());
159
+ */
160
+ export function subscribeSessionRevocation(source, onRevoked) {
161
+ source.on("publication", (ctx) => {
162
+ const data = ctx?.data;
163
+ if (data?.type === "session.revoked")
164
+ onRevoked();
165
+ });
166
+ }
@@ -0,0 +1,91 @@
1
+ export interface OnlineCount {
2
+ /** Unique signed-in identities online. */
3
+ users: number;
4
+ /** Total connections (a user with 2 tabs counts as 2). */
5
+ clients: number;
6
+ }
7
+ /**
8
+ * How many of this product's users are online right now. Calls Dialtribe's
9
+ * realtime online endpoint with the product's secret key (dt_sk_...); the count
10
+ * is scoped to this product server-side, so you only ever see your own.
11
+ */
12
+ export declare function getServiceOnlineCount(opts: {
13
+ secretKey: string;
14
+ apiBase?: string;
15
+ }): Promise<OnlineCount>;
16
+ export interface DialtribeProviderOptions {
17
+ clientId: string;
18
+ clientSecret: string;
19
+ /** Override the discovery URL (default Dialtribe prod). */
20
+ discoveryUrl?: string;
21
+ /** Extra scopes on top of openid/email/profile. */
22
+ extraScopes?: string[];
23
+ }
24
+ /**
25
+ * The better-auth `genericOAuth` provider config for Dialtribe, with PKCE baked
26
+ * in (the Dialtribe provider requires it). Spread into the plugin:
27
+ *
28
+ * genericOAuth({ config: [dialtribeProvider({ clientId, clientSecret })] })
29
+ */
30
+ export declare function dialtribeProvider(opts: DialtribeProviderOptions): {
31
+ providerId: string;
32
+ discoveryUrl: string;
33
+ clientId: string;
34
+ clientSecret: string;
35
+ scopes: string[];
36
+ pkce: true;
37
+ };
38
+ /**
39
+ * Recommended top-level better-auth `account` options for a Dialtribe client.
40
+ * The cookie state strategy (10 min) matches Dialtribe's OTP window; the default
41
+ * database strategy's 5-minute state cookie causes `state_mismatch` when the
42
+ * user takes longer than 5 min on the Dialtribe sign-in screen. Spread into the
43
+ * better-auth config: `account: { ...recommendedAccountOptions }`.
44
+ */
45
+ export declare const recommendedAccountOptions: {
46
+ storeStateStrategy: "cookie";
47
+ };
48
+ export interface SessionStatus {
49
+ /** ms epoch the identity was globally signed out, or null if never. */
50
+ revokedAt: number | null;
51
+ }
52
+ /**
53
+ * Poll Dialtribe for an identity's global revocation watermark. Call this on a
54
+ * bounded cadence (e.g. every 5 min, cached) from your session-reading path and
55
+ * sign the user out locally if `revokedAt` is newer than the local session's
56
+ * creation time. Fail-open: on a network error, treat the session as valid
57
+ * (don't let a Dialtribe outage log everyone out).
58
+ *
59
+ * Authenticated with your OIDC client credentials (the same client_id/secret you
60
+ * use for sign-in).
61
+ */
62
+ export declare function getSessionStatus(opts: {
63
+ clientId: string;
64
+ clientSecret: string;
65
+ sub: string;
66
+ apiBase?: string;
67
+ }): Promise<SessionStatus>;
68
+ export interface BackchannelLogoutConfig {
69
+ /** This app's Dialtribe client_id (the logout token's audience). */
70
+ clientId: string;
71
+ /** Override the discovery URL (default Dialtribe prod). */
72
+ discoveryUrl?: string;
73
+ /**
74
+ * Delete this app's local sessions for the given Dialtribe subject. This is
75
+ * the one app-specific piece (your DB / session schema). Only delete local
76
+ * sessions here; never call back to the OP (loop risk).
77
+ */
78
+ deleteSessionsForSub: (sub: string) => Promise<void> | void;
79
+ }
80
+ /**
81
+ * Build a Web-standard POST handler that receives Dialtribe's signed Logout
82
+ * Token, verifies it against Dialtribe's JWKS, and signs the subject out of this
83
+ * app by calling `deleteSessionsForSub`. Assign it directly in a route:
84
+ *
85
+ * // app/api/auth/backchannel-logout/route.ts
86
+ * export const POST = createBackchannelLogoutHandler({
87
+ * clientId: process.env.DIALTRIBE_OIDC_CLIENT_ID!,
88
+ * deleteSessionsForSub: async (sub) => { ...delete local sessions... },
89
+ * });
90
+ */
91
+ export declare function createBackchannelLogoutHandler(cfg: BackchannelLogoutConfig): (request: Request) => Promise<Response>;
package/dist/server.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @dialtribe/auth/server - server helpers for a Dialtribe Identity relying party.
3
+ *
4
+ * Depends on `jose` (JWT verification). Keep this out of client bundles; import
5
+ * from "@dialtribe/auth/client" in the browser.
6
+ */
7
+ import { createRemoteJWKSet, jwtVerify } from "jose";
8
+ const DEFAULT_DISCOVERY = "https://id.dialtribe.com/api/auth/.well-known/openid-configuration";
9
+ const DEFAULT_API_BASE = "https://id.dialtribe.com";
10
+ const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
11
+ /**
12
+ * How many of this product's users are online right now. Calls Dialtribe's
13
+ * realtime online endpoint with the product's secret key (dt_sk_...); the count
14
+ * is scoped to this product server-side, so you only ever see your own.
15
+ */
16
+ export async function getServiceOnlineCount(opts) {
17
+ const base = opts.apiBase ?? DEFAULT_API_BASE;
18
+ const res = await fetch(`${base}/api/public/v1/realtime/online`, {
19
+ headers: { Authorization: `Bearer ${opts.secretKey}` },
20
+ });
21
+ if (!res.ok)
22
+ throw new Error(`online count failed: ${res.status}`);
23
+ const data = (await res.json());
24
+ return { users: data.users ?? 0, clients: data.clients ?? 0 };
25
+ }
26
+ /**
27
+ * The better-auth `genericOAuth` provider config for Dialtribe, with PKCE baked
28
+ * in (the Dialtribe provider requires it). Spread into the plugin:
29
+ *
30
+ * genericOAuth({ config: [dialtribeProvider({ clientId, clientSecret })] })
31
+ */
32
+ export function dialtribeProvider(opts) {
33
+ return {
34
+ providerId: "dialtribe",
35
+ discoveryUrl: opts.discoveryUrl ?? DEFAULT_DISCOVERY,
36
+ clientId: opts.clientId,
37
+ clientSecret: opts.clientSecret,
38
+ scopes: ["openid", "email", "profile", ...(opts.extraScopes ?? [])],
39
+ pkce: true,
40
+ };
41
+ }
42
+ /**
43
+ * Recommended top-level better-auth `account` options for a Dialtribe client.
44
+ * The cookie state strategy (10 min) matches Dialtribe's OTP window; the default
45
+ * database strategy's 5-minute state cookie causes `state_mismatch` when the
46
+ * user takes longer than 5 min on the Dialtribe sign-in screen. Spread into the
47
+ * better-auth config: `account: { ...recommendedAccountOptions }`.
48
+ */
49
+ export const recommendedAccountOptions = {
50
+ storeStateStrategy: "cookie",
51
+ };
52
+ /**
53
+ * Poll Dialtribe for an identity's global revocation watermark. Call this on a
54
+ * bounded cadence (e.g. every 5 min, cached) from your session-reading path and
55
+ * sign the user out locally if `revokedAt` is newer than the local session's
56
+ * creation time. Fail-open: on a network error, treat the session as valid
57
+ * (don't let a Dialtribe outage log everyone out).
58
+ *
59
+ * Authenticated with your OIDC client credentials (the same client_id/secret you
60
+ * use for sign-in).
61
+ */
62
+ export async function getSessionStatus(opts) {
63
+ const base = opts.apiBase ?? DEFAULT_API_BASE;
64
+ const basic = Buffer.from(`${opts.clientId}:${opts.clientSecret}`).toString("base64");
65
+ const res = await fetch(`${base}/api/public/v1/identity/session-status`, {
66
+ method: "POST",
67
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
68
+ body: JSON.stringify({ sub: opts.sub }),
69
+ });
70
+ if (!res.ok)
71
+ throw new Error(`session-status failed: ${res.status}`);
72
+ const data = (await res.json());
73
+ return { revokedAt: typeof data.revokedAt === "number" ? data.revokedAt : null };
74
+ }
75
+ function jsonError(error) {
76
+ // Spec: a bad logout token gets a 400 with an { error } body.
77
+ return new Response(JSON.stringify({ error }), {
78
+ status: 400,
79
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
80
+ });
81
+ }
82
+ /**
83
+ * Build a Web-standard POST handler that receives Dialtribe's signed Logout
84
+ * Token, verifies it against Dialtribe's JWKS, and signs the subject out of this
85
+ * app by calling `deleteSessionsForSub`. Assign it directly in a route:
86
+ *
87
+ * // app/api/auth/backchannel-logout/route.ts
88
+ * export const POST = createBackchannelLogoutHandler({
89
+ * clientId: process.env.DIALTRIBE_OIDC_CLIENT_ID!,
90
+ * deleteSessionsForSub: async (sub) => { ...delete local sessions... },
91
+ * });
92
+ */
93
+ export function createBackchannelLogoutHandler(cfg) {
94
+ const discoveryUrl = cfg.discoveryUrl ?? DEFAULT_DISCOVERY;
95
+ // Resolve issuer + JWKS once; createRemoteJWKSet caches keys and refetches on
96
+ // an unknown kid, so key rotation is handled for us.
97
+ let discoveryPromise = null;
98
+ const getDiscovery = () => {
99
+ if (!discoveryPromise) {
100
+ discoveryPromise = (async () => {
101
+ const res = await fetch(discoveryUrl);
102
+ if (!res.ok)
103
+ throw new Error(`discovery fetch failed: ${res.status}`);
104
+ const doc = (await res.json());
105
+ if (!doc.issuer || !doc.jwks_uri)
106
+ throw new Error("discovery missing issuer/jwks_uri");
107
+ return { issuer: doc.issuer, jwks: createRemoteJWKSet(new URL(doc.jwks_uri)) };
108
+ })().catch((err) => {
109
+ discoveryPromise = null; // allow retry on the next request
110
+ throw err;
111
+ });
112
+ }
113
+ return discoveryPromise;
114
+ };
115
+ return async function POST(request) {
116
+ if (!cfg.clientId)
117
+ return jsonError("client not configured");
118
+ const form = await request.formData().catch(() => null);
119
+ const token = form?.get("logout_token");
120
+ if (typeof token !== "string" || !token)
121
+ return jsonError("missing logout_token");
122
+ let payload;
123
+ try {
124
+ const { issuer, jwks } = await getDiscovery();
125
+ const verified = await jwtVerify(token, jwks, {
126
+ issuer,
127
+ audience: cfg.clientId,
128
+ typ: "logout+jwt",
129
+ });
130
+ payload = verified.payload;
131
+ }
132
+ catch {
133
+ return jsonError("invalid logout_token");
134
+ }
135
+ // MUST carry the back-channel logout event and MUST NOT carry a nonce.
136
+ const events = payload.events;
137
+ if (!events || typeof events !== "object" || !(BACKCHANNEL_LOGOUT_EVENT in events)) {
138
+ return jsonError("logout_token missing backchannel-logout event");
139
+ }
140
+ if ("nonce" in payload)
141
+ return jsonError("logout_token must not contain a nonce");
142
+ const sub = payload.sub;
143
+ if (!sub)
144
+ return jsonError("logout_token missing sub");
145
+ await cfg.deleteSessionsForSub(sub);
146
+ return new Response(JSON.stringify({ ok: true }), {
147
+ status: 200,
148
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
149
+ });
150
+ };
151
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@dialtribe/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Relying-party SDK for Dialtribe Identity (OIDC SSO): sign-in, global sign-out, guest-work resume, and back-channel logout.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ "./client": {
9
+ "types": "./dist/client.d.ts",
10
+ "import": "./dist/client.js"
11
+ },
12
+ "./server": {
13
+ "types": "./dist/server.d.ts",
14
+ "import": "./dist/server.js"
15
+ }
16
+ },
17
+ "typesVersions": {
18
+ "*": {
19
+ "client": ["dist/client.d.ts"],
20
+ "server": ["dist/server.d.ts"]
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json",
28
+ "prepublishOnly": "yarn build"
29
+ },
30
+ "dependencies": {
31
+ "jose": "^6"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22",
35
+ "typescript": "^5"
36
+ },
37
+ "license": "UNLICENSED",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }