@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.
- package/package.json +20 -4
- package/src/index.ts +259 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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