@dialtribe/auth 0.0.0-stage → 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 +89 -2
- package/dist/client.d.ts +86 -0
- package/dist/client.js +166 -0
- package/dist/server.d.ts +91 -0
- package/dist/server.js +151 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dialtribe/auth
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|