@riboseinc/ribose-identity-client 0.0.0-stage → 0.2.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.
Files changed (3) hide show
  1. package/package.json +20 -4
  2. package/src/index.ts +259 -0
  3. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "name": "@riboseinc/ribose-identity-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest run --dir test"
12
+ },
13
+ "devDependencies": {
14
+ "@riboseinc/ribose-identity-kernel": "*"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "src"
21
+ ]
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * @riboseinc/ribose-identity-client — the browser-only relying-party SDK for a
3
+ * Ribose ID instance (any OIDC provider with the same profile:
4
+ * authorization code + PKCE S256, ES256 ID tokens, opaque reference
5
+ * tokens).
6
+ *
7
+ * The Auth0/Clerk shape, ~150 lines, ZERO dependencies, pure browser:
8
+ * `signIn()` sends the user's browser to /authorize with a freshly
9
+ * generated PKCE pair; `handleCallback()` verifies state, exchanges
10
+ * the code at /token, and decodes the ID-token payload; `signOut()`
11
+ * drops the local state. The PKCE verifier and state live in
12
+ * sessionStorage, namespaced per issuer + client_id, so several RPs
13
+ * can share one origin without colliding.
14
+ *
15
+ * The SDK never verifies the ID-token SIGNATURE — that is a
16
+ * server-side job with the provider's JWKS (see the instance's
17
+ * relying-party integration manual). It returns the decoded payload
18
+ * for the client-side claims a SPA may want (nonce, email), plus the
19
+ * raw tokens for the RP's own verifier.
20
+ */
21
+
22
+ export interface RPConfig {
23
+ /** The instance's issuer URL, exactly as discovery advertises it. */
24
+ issuer: string;
25
+ clientId: string;
26
+ redirectUri: string;
27
+ }
28
+
29
+ export interface SignInOptions {
30
+ /** Bind the callback to this request; generated when omitted. */
31
+ state?: string;
32
+ /** Sent through to the ID token; generated when omitted. */
33
+ nonce?: string;
34
+ /** `openid email` when omitted (everything else the instance ignores). */
35
+ scope?: string;
36
+ }
37
+
38
+ /** What `handleCallback()` resolves with: the grant plus the decoded
39
+ * ID-token payload (signature verification stays the RP's job). */
40
+ export interface CallbackResult {
41
+ idToken: string;
42
+ accessToken: string;
43
+ refreshToken: string;
44
+ claims: Record<string, unknown>;
45
+ }
46
+
47
+ export interface RelyingParty {
48
+ signIn(options?: SignInOptions): Promise<void>;
49
+ handleCallback(): Promise<CallbackResult>;
50
+ signOut(): void;
51
+ }
52
+
53
+ export function configure(config: RPConfig): RelyingParty {
54
+ return new BrowserRelyingParty(config);
55
+ }
56
+
57
+ // ── pure helpers (exported for reuse and for specs) ─────────────────
58
+
59
+ /** The issuer with any trailing slash removed — endpoint URLs hang
60
+ * off this base. */
61
+ export function issuerBase(issuer: string): string {
62
+ return issuer.replace(/\/+$/, "");
63
+ }
64
+
65
+ /** RFC 7636 S256 challenge derivation over the Web Crypto API — the
66
+ * browser twin of the OP kernel's `pkceChallenge` (SHA-256,
67
+ * base64url, no padding). */
68
+ export async function pkceChallenge(verifier: string): Promise<string> {
69
+ const digest = await crypto.subtle.digest(
70
+ "SHA-256",
71
+ new TextEncoder().encode(verifier),
72
+ );
73
+ return base64url(new Uint8Array(digest));
74
+ }
75
+
76
+ /** A fresh PKCE pair: 32 random bytes of verifier (43 unreserved
77
+ * characters — inside the RFC 7636 43–128 band) plus its challenge. */
78
+ export async function createPkcePair(): Promise<{
79
+ verifier: string;
80
+ challenge: string;
81
+ }> {
82
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
83
+ return { verifier, challenge: await pkceChallenge(verifier) };
84
+ }
85
+
86
+ /** A random unguessable value (state/nonce material). */
87
+ export function randomToken(byteLength = 24): string {
88
+ return base64url(crypto.getRandomValues(new Uint8Array(byteLength)));
89
+ }
90
+
91
+ /** base64url (RFC 4648 §5): URL-safe alphabet, no padding. */
92
+ export function base64url(bytes: Uint8Array): string {
93
+ let binary = "";
94
+ for (const byte of bytes) binary += String.fromCharCode(byte);
95
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
96
+ }
97
+
98
+ export interface AuthorizeUrlParams {
99
+ codeChallenge: string;
100
+ state: string;
101
+ nonce?: string;
102
+ scope?: string;
103
+ }
104
+
105
+ /** The exact /authorize query the OP validates: code + PKCE S256,
106
+ * always with state and (by default) a nonce. Pure — no browser. */
107
+ export function buildAuthorizeUrl(
108
+ config: RPConfig,
109
+ params: AuthorizeUrlParams,
110
+ ): string {
111
+ const url = new URL(`${issuerBase(config.issuer)}/authorize`);
112
+ url.searchParams.set("response_type", "code");
113
+ url.searchParams.set("client_id", config.clientId);
114
+ url.searchParams.set("redirect_uri", config.redirectUri);
115
+ url.searchParams.set("code_challenge", params.codeChallenge);
116
+ url.searchParams.set("code_challenge_method", "S256");
117
+ url.searchParams.set("state", params.state);
118
+ if (params.nonce !== undefined) url.searchParams.set("nonce", params.nonce);
119
+ url.searchParams.set("scope", params.scope ?? "openid email");
120
+ return url.toString();
121
+ }
122
+
123
+ export interface CallbackQuery {
124
+ code: string | undefined;
125
+ state: string | undefined;
126
+ error: string | undefined;
127
+ }
128
+
129
+ /** Reads code/state/error off a callback URL. Pure — no browser. */
130
+ export function parseCallback(url: string): CallbackQuery {
131
+ const params = new URL(url).searchParams;
132
+ const pick = (key: string): string | undefined => {
133
+ const value = params.get(key);
134
+ return value === null ? undefined : value;
135
+ };
136
+ return { code: pick("code"), state: pick("state"), error: pick("error") };
137
+ }
138
+
139
+ /** Decodes (does NOT verify) the ID token's payload segment. */
140
+ export function decodeIdTokenPayload(
141
+ idToken: string,
142
+ ): Record<string, unknown> {
143
+ const parts = idToken.split(".");
144
+ if (parts.length !== 3) {
145
+ throw new Error("not a JWS compact serialization (three parts expected)");
146
+ }
147
+ const base64 = parts[1]!.replaceAll("-", "+").replaceAll("_", "/");
148
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
149
+ const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
150
+ return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
151
+ }
152
+
153
+ // ── the browser implementation ──────────────────────────────────────
154
+
155
+ /** One sessionStorage namespace per issuer + client_id, so multiple
156
+ * RPs on one origin never read each other's sign-in state. */
157
+ function sessionKeys(config: RPConfig): Record<"verifier" | "state" | "nonce", string> {
158
+ const scope = `ribose-id.rp:${issuerBase(config.issuer)}:${config.clientId}`;
159
+ return {
160
+ verifier: `${scope}.verifier`,
161
+ state: `${scope}.state`,
162
+ nonce: `${scope}.nonce`,
163
+ };
164
+ }
165
+
166
+ class BrowserRelyingParty implements RelyingParty {
167
+ private readonly keys: ReturnType<typeof sessionKeys>;
168
+
169
+ constructor(private readonly config: RPConfig) {
170
+ this.keys = sessionKeys(config);
171
+ }
172
+
173
+ async signIn(options: SignInOptions = {}): Promise<void> {
174
+ const pkce = await createPkcePair();
175
+ const state = options.state ?? randomToken();
176
+ const nonce = options.nonce ?? randomToken();
177
+ const storage = window.sessionStorage;
178
+ storage.setItem(this.keys.verifier, pkce.verifier);
179
+ storage.setItem(this.keys.state, state);
180
+ storage.setItem(this.keys.nonce, nonce);
181
+ window.location.assign(
182
+ buildAuthorizeUrl(this.config, {
183
+ codeChallenge: pkce.challenge,
184
+ state,
185
+ nonce,
186
+ scope: options.scope ?? "openid",
187
+ }),
188
+ );
189
+ }
190
+
191
+ async handleCallback(): Promise<CallbackResult> {
192
+ const callback = parseCallback(window.location.href);
193
+ if (callback.error !== undefined) {
194
+ throw new Error(`authorization failed: ${callback.error}`);
195
+ }
196
+ if (callback.code === undefined) {
197
+ throw new Error("no authorization code in the callback URL");
198
+ }
199
+ const state = window.sessionStorage.getItem(this.keys.state);
200
+ if (
201
+ callback.state === undefined ||
202
+ state === null ||
203
+ callback.state !== state
204
+ ) {
205
+ throw new Error(
206
+ "state mismatch — the callback does not belong to this browser's sign-in",
207
+ );
208
+ }
209
+ const verifier = window.sessionStorage.getItem(this.keys.verifier);
210
+ if (verifier === null) {
211
+ throw new Error("no sign-in in progress — call signIn() first");
212
+ }
213
+ const response = await fetch(`${issuerBase(this.config.issuer)}/token`, {
214
+ method: "POST",
215
+ headers: { "content-type": "application/x-www-form-urlencoded" },
216
+ body: new URLSearchParams({
217
+ grant_type: "authorization_code",
218
+ client_id: this.config.clientId,
219
+ code: callback.code,
220
+ redirect_uri: this.config.redirectUri,
221
+ code_verifier: verifier,
222
+ }),
223
+ });
224
+ const grant = (await response.json().catch(() => null)) as {
225
+ id_token?: string;
226
+ access_token?: string;
227
+ refresh_token?: string;
228
+ error?: string;
229
+ error_description?: string;
230
+ } | null;
231
+ if (
232
+ !response.ok ||
233
+ grant === null ||
234
+ grant.id_token === undefined ||
235
+ grant.access_token === undefined
236
+ ) {
237
+ const reason =
238
+ grant?.error_description ??
239
+ grant?.error ??
240
+ `HTTP ${String(response.status)}`;
241
+ throw new Error(`token exchange failed: ${reason}`);
242
+ }
243
+ const result: CallbackResult = {
244
+ idToken: grant.id_token,
245
+ accessToken: grant.access_token,
246
+ refreshToken: grant.refresh_token ?? "",
247
+ claims: decodeIdTokenPayload(grant.id_token),
248
+ };
249
+ this.signOut();
250
+ return result;
251
+ }
252
+
253
+ signOut(): void {
254
+ const storage = window.sessionStorage;
255
+ for (const key of [this.keys.verifier, this.keys.state, this.keys.nonce]) {
256
+ storage.removeItem(key);
257
+ }
258
+ }
259
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.