@ericminassian/auth 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 +90 -0
- package/dist/auth-client-DZWvgw3X.d.ts +59 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +234 -0
- package/dist/index-CiPxwNnj.d.ts +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/react/index.d.ts +23 -0
- package/dist/react/index.js +31 -0
- package/dist/server/express.d.ts +18 -0
- package/dist/server/express.js +22 -0
- package/dist/server/hono.d.ts +16 -0
- package/dist/server/hono.js +16 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.js +72 -0
- package/dist/src-C0axRlLQ.js +18 -0
- package/dist/verify-QJxbHc6P.d.ts +40 -0
- package/package.json +84 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @ericminassian/auth
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for [auth.ericminassian.com](https://auth.ericminassian.com) — the
|
|
4
|
+
OIDC provider for `*.ericminassian.com` apps. ESM-only; subpath exports keep the
|
|
5
|
+
browser and server surfaces separate.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @ericminassian/auth
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Browser (`/client`, `/react`)
|
|
12
|
+
|
|
13
|
+
Authorization code + PKCE, run entirely in the browser:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createAuthClient } from "@ericminassian/auth/client";
|
|
17
|
+
|
|
18
|
+
const auth = createAuthClient({
|
|
19
|
+
clientId: "my-app",
|
|
20
|
+
redirectUri: "https://my-app.ericminassian.com/callback",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Kick off login
|
|
24
|
+
await auth.signInWithRedirect();
|
|
25
|
+
|
|
26
|
+
// On your /callback route
|
|
27
|
+
const { returnTo } = await auth.handleRedirectCallback();
|
|
28
|
+
|
|
29
|
+
// Call your API
|
|
30
|
+
const token = await auth.getAccessToken();
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
React bindings:
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { createAuthClient } from "@ericminassian/auth/client";
|
|
37
|
+
import { AuthProvider, useAuth, useUser } from "@ericminassian/auth/react";
|
|
38
|
+
|
|
39
|
+
const client = createAuthClient({ clientId: "my-app", redirectUri: "…/callback" });
|
|
40
|
+
|
|
41
|
+
function App() {
|
|
42
|
+
return (
|
|
43
|
+
<AuthProvider client={client}>
|
|
44
|
+
<Profile />
|
|
45
|
+
</AuthProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Profile() {
|
|
50
|
+
const { state, signIn, signOut } = useAuth();
|
|
51
|
+
const user = useUser();
|
|
52
|
+
if (state.status !== "authenticated") return <button onClick={() => signIn()}>Sign in</button>;
|
|
53
|
+
return <button onClick={() => signOut()}>Sign out {user?.email}</button>;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Server (`/server`, `/server/hono`, `/server/express`)
|
|
58
|
+
|
|
59
|
+
Verify access tokens locally against the JWKS (no network call per request, edge-safe):
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { createAuthVerifier } from "@ericminassian/auth/server";
|
|
63
|
+
|
|
64
|
+
const verifier = createAuthVerifier({ audience: "my-app" });
|
|
65
|
+
|
|
66
|
+
const result = await verifier.authenticateRequest(request);
|
|
67
|
+
if (result.authenticated) {
|
|
68
|
+
console.log(result.claims.sub, result.claims.scope);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Framework middleware:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { authMiddleware } from "@ericminassian/auth/server/hono";
|
|
76
|
+
app.use("/api/*", authMiddleware(verifier)); // claims at c.var.auth
|
|
77
|
+
|
|
78
|
+
import { requireAuth } from "@ericminassian/auth/server/express";
|
|
79
|
+
app.use("/api", requireAuth(verifier)); // claims at req.auth
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`verifyLogoutToken` validates back-channel logout tokens at your RP's logout receiver.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
pnpm generate # regenerate wire types from ../../openapi/openapi.json
|
|
88
|
+
pnpm build # tsdown → dist/
|
|
89
|
+
pnpm test # vitest
|
|
90
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { i as User } from "./index-CiPxwNnj.js";
|
|
2
|
+
|
|
3
|
+
//#region src/client/storage.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pluggable persistence for the refresh token and the in-flight authorization
|
|
7
|
+
* transaction (PKCE verifier + state). Access tokens are never persisted —
|
|
8
|
+
* they live in memory only.
|
|
9
|
+
*/
|
|
10
|
+
interface TokenStorage {
|
|
11
|
+
get(key: string): string | null;
|
|
12
|
+
set(key: string, value: string): void;
|
|
13
|
+
remove(key: string): void;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/client/auth-client.d.ts
|
|
17
|
+
interface AuthClientOptions {
|
|
18
|
+
clientId: string;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
/** Defaults to `https://auth.ericminassian.com`. */
|
|
21
|
+
issuer?: string;
|
|
22
|
+
/** Defaults to `openid email offline_access`. */
|
|
23
|
+
scope?: string;
|
|
24
|
+
storage?: TokenStorage;
|
|
25
|
+
}
|
|
26
|
+
type AuthState = {
|
|
27
|
+
status: "loading";
|
|
28
|
+
} | {
|
|
29
|
+
status: "authenticated";
|
|
30
|
+
user: User;
|
|
31
|
+
} | {
|
|
32
|
+
status: "unauthenticated";
|
|
33
|
+
};
|
|
34
|
+
interface SignInOptions {
|
|
35
|
+
/** Where to return after the callback completes. Defaults to the current URL. */
|
|
36
|
+
returnTo?: string;
|
|
37
|
+
}
|
|
38
|
+
interface AuthClient {
|
|
39
|
+
/** Build a PKCE+state transaction and navigate to the authorize endpoint. */
|
|
40
|
+
signInWithRedirect(options?: SignInOptions): Promise<void>;
|
|
41
|
+
/** Complete the redirect: exchange the code for tokens. Returns the saved returnTo. */
|
|
42
|
+
handleRedirectCallback(url?: string): Promise<{
|
|
43
|
+
returnTo: string | undefined;
|
|
44
|
+
}>;
|
|
45
|
+
/** A valid access token, refreshing if necessary. Throws `login_required` if not signed in. */
|
|
46
|
+
getAccessToken(options?: {
|
|
47
|
+
forceRefresh?: boolean;
|
|
48
|
+
}): Promise<string>;
|
|
49
|
+
getUser(): User | undefined;
|
|
50
|
+
getState(): AuthState;
|
|
51
|
+
onStateChange(listener: (state: AuthState) => void): () => void;
|
|
52
|
+
/** Revoke the refresh token, clear local state, and navigate to end_session. */
|
|
53
|
+
signOut(options?: {
|
|
54
|
+
postLogoutRedirectUri?: string;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
declare function createAuthClient(options: AuthClientOptions): AuthClient;
|
|
58
|
+
//#endregion
|
|
59
|
+
export { createAuthClient as a, SignInOptions as i, AuthClientOptions as n, TokenStorage as o, AuthState as r, AuthClient as t };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { i as User, n as AuthErrorCode, r as DEFAULT_ISSUER, t as AuthError } from "../index-CiPxwNnj.js";
|
|
2
|
+
import { a as createAuthClient, i as SignInOptions, n as AuthClientOptions, o as TokenStorage, r as AuthState, t as AuthClient } from "../auth-client-DZWvgw3X.js";
|
|
3
|
+
export { type AuthClient, type AuthClientOptions, AuthError, type AuthErrorCode, type AuthState, DEFAULT_ISSUER, type SignInOptions, type TokenStorage, type User, createAuthClient };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { n as DEFAULT_ISSUER, t as AuthError } from "../src-C0axRlLQ.js";
|
|
2
|
+
|
|
3
|
+
//#region src/client/pkce.ts
|
|
4
|
+
/** PKCE (RFC 7636) S256 helpers, built on the Web Crypto API. */
|
|
5
|
+
const VERIFIER_BYTES = 32;
|
|
6
|
+
function base64url(bytes) {
|
|
7
|
+
let binary = "";
|
|
8
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
9
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
10
|
+
}
|
|
11
|
+
function randomBase64url(byteLength) {
|
|
12
|
+
const bytes = new Uint8Array(byteLength);
|
|
13
|
+
crypto.getRandomValues(bytes);
|
|
14
|
+
return base64url(bytes);
|
|
15
|
+
}
|
|
16
|
+
function createState() {
|
|
17
|
+
return randomBase64url(16);
|
|
18
|
+
}
|
|
19
|
+
async function createPkcePair() {
|
|
20
|
+
const verifier = randomBase64url(VERIFIER_BYTES);
|
|
21
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
22
|
+
return {
|
|
23
|
+
verifier,
|
|
24
|
+
challenge: base64url(new Uint8Array(digest))
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/client/storage.ts
|
|
30
|
+
/** Default storage: `sessionStorage` when available, else an in-memory map. */
|
|
31
|
+
function defaultStorage() {
|
|
32
|
+
if (typeof sessionStorage !== "undefined") return {
|
|
33
|
+
get: (key) => sessionStorage.getItem(key),
|
|
34
|
+
set: (key, value) => sessionStorage.setItem(key, value),
|
|
35
|
+
remove: (key) => sessionStorage.removeItem(key)
|
|
36
|
+
};
|
|
37
|
+
const map = /* @__PURE__ */ new Map();
|
|
38
|
+
return {
|
|
39
|
+
get: (key) => map.get(key) ?? null,
|
|
40
|
+
set: (key, value) => {
|
|
41
|
+
map.set(key, value);
|
|
42
|
+
},
|
|
43
|
+
remove: (key) => {
|
|
44
|
+
map.delete(key);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/client/auth-client.ts
|
|
51
|
+
const TX_KEY = "ema_auth_tx";
|
|
52
|
+
const RT_KEY = "ema_auth_rt";
|
|
53
|
+
const ID_KEY = "ema_auth_id";
|
|
54
|
+
const EXPIRY_SKEW_SECONDS = 30;
|
|
55
|
+
function createAuthClient(options) {
|
|
56
|
+
const issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
57
|
+
const scope = options.scope ?? "openid email offline_access";
|
|
58
|
+
const storage = options.storage ?? defaultStorage();
|
|
59
|
+
let discovery;
|
|
60
|
+
let cachedToken;
|
|
61
|
+
let user = decodeStoredUser(storage);
|
|
62
|
+
let state = user ? {
|
|
63
|
+
status: "authenticated",
|
|
64
|
+
user
|
|
65
|
+
} : { status: "unauthenticated" };
|
|
66
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
67
|
+
function setState(next) {
|
|
68
|
+
state = next;
|
|
69
|
+
user = next.status === "authenticated" ? next.user : void 0;
|
|
70
|
+
for (const listener of listeners) listener(state);
|
|
71
|
+
}
|
|
72
|
+
function getDiscovery() {
|
|
73
|
+
discovery ??= fetchJson(`${issuer}/.well-known/openid-configuration`).catch(() => {
|
|
74
|
+
discovery = void 0;
|
|
75
|
+
throw new AuthError("network_error", "failed to load OIDC discovery document");
|
|
76
|
+
});
|
|
77
|
+
return discovery;
|
|
78
|
+
}
|
|
79
|
+
async function exchange(body) {
|
|
80
|
+
const { token_endpoint } = await getDiscovery();
|
|
81
|
+
const response = await fetch(token_endpoint, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
84
|
+
body: new URLSearchParams(body)
|
|
85
|
+
});
|
|
86
|
+
if (!response.ok) throw new AuthError(body.grant_type === "refresh_token" ? "token_refresh_failed" : "invalid_grant", "token endpoint rejected the request");
|
|
87
|
+
const tokens = await response.json();
|
|
88
|
+
cachedToken = {
|
|
89
|
+
accessToken: tokens.access_token,
|
|
90
|
+
expiresAt: Date.now() + (tokens.expires_in - EXPIRY_SKEW_SECONDS) * 1e3
|
|
91
|
+
};
|
|
92
|
+
if (tokens.refresh_token) storage.set(RT_KEY, tokens.refresh_token);
|
|
93
|
+
if (tokens.id_token) {
|
|
94
|
+
storage.set(ID_KEY, tokens.id_token);
|
|
95
|
+
const next = userFromIdToken(tokens.id_token);
|
|
96
|
+
if (next) setState({
|
|
97
|
+
status: "authenticated",
|
|
98
|
+
user: next
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
async signInWithRedirect(signInOptions) {
|
|
104
|
+
const { authorization_endpoint } = await getDiscovery();
|
|
105
|
+
const pkce = await createPkcePair();
|
|
106
|
+
const tx = {
|
|
107
|
+
verifier: pkce.verifier,
|
|
108
|
+
state: createState(),
|
|
109
|
+
returnTo: signInOptions?.returnTo ?? currentUrl()
|
|
110
|
+
};
|
|
111
|
+
storage.set(TX_KEY, JSON.stringify(tx));
|
|
112
|
+
const url = new URL(authorization_endpoint);
|
|
113
|
+
url.search = new URLSearchParams({
|
|
114
|
+
response_type: "code",
|
|
115
|
+
client_id: options.clientId,
|
|
116
|
+
redirect_uri: options.redirectUri,
|
|
117
|
+
scope,
|
|
118
|
+
state: tx.state,
|
|
119
|
+
code_challenge: pkce.challenge,
|
|
120
|
+
code_challenge_method: "S256"
|
|
121
|
+
}).toString();
|
|
122
|
+
redirect(url.toString());
|
|
123
|
+
},
|
|
124
|
+
async handleRedirectCallback(url) {
|
|
125
|
+
const params = new URL(url ?? currentUrl()).searchParams;
|
|
126
|
+
const raw = storage.get(TX_KEY);
|
|
127
|
+
storage.remove(TX_KEY);
|
|
128
|
+
if (!raw) throw new AuthError("state_mismatch", "no authorization transaction in progress");
|
|
129
|
+
const tx = JSON.parse(raw);
|
|
130
|
+
if (params.get("error")) throw new AuthError("invalid_grant", params.get("error_description") ?? params.get("error") ?? "authorization failed");
|
|
131
|
+
if (params.get("state") !== tx.state) throw new AuthError("state_mismatch", "state parameter mismatch");
|
|
132
|
+
const code = params.get("code");
|
|
133
|
+
if (!code) throw new AuthError("invalid_grant", "missing authorization code");
|
|
134
|
+
await exchange({
|
|
135
|
+
grant_type: "authorization_code",
|
|
136
|
+
code,
|
|
137
|
+
redirect_uri: options.redirectUri,
|
|
138
|
+
client_id: options.clientId,
|
|
139
|
+
code_verifier: tx.verifier
|
|
140
|
+
});
|
|
141
|
+
return { returnTo: tx.returnTo };
|
|
142
|
+
},
|
|
143
|
+
async getAccessToken(getOptions) {
|
|
144
|
+
if (!getOptions?.forceRefresh && cachedToken && cachedToken.expiresAt > Date.now()) return cachedToken.accessToken;
|
|
145
|
+
const refreshToken = storage.get(RT_KEY);
|
|
146
|
+
if (!refreshToken) throw new AuthError("login_required", "no refresh token available");
|
|
147
|
+
try {
|
|
148
|
+
await exchange({
|
|
149
|
+
grant_type: "refresh_token",
|
|
150
|
+
refresh_token: refreshToken,
|
|
151
|
+
client_id: options.clientId
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
storage.remove(RT_KEY);
|
|
155
|
+
storage.remove(ID_KEY);
|
|
156
|
+
setState({ status: "unauthenticated" });
|
|
157
|
+
if (error instanceof AuthError) throw new AuthError("login_required", error.message);
|
|
158
|
+
throw new AuthError("login_required");
|
|
159
|
+
}
|
|
160
|
+
if (!cachedToken) throw new AuthError("login_required");
|
|
161
|
+
return cachedToken.accessToken;
|
|
162
|
+
},
|
|
163
|
+
getUser: () => user,
|
|
164
|
+
getState: () => state,
|
|
165
|
+
onStateChange(listener) {
|
|
166
|
+
listeners.add(listener);
|
|
167
|
+
return () => listeners.delete(listener);
|
|
168
|
+
},
|
|
169
|
+
async signOut(signOutOptions) {
|
|
170
|
+
const idToken = storage.get(ID_KEY);
|
|
171
|
+
const refreshToken = storage.get(RT_KEY);
|
|
172
|
+
const { end_session_endpoint, revocation_endpoint } = await getDiscovery();
|
|
173
|
+
if (refreshToken) await fetch(revocation_endpoint, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
176
|
+
body: new URLSearchParams({ token: refreshToken })
|
|
177
|
+
}).catch(() => void 0);
|
|
178
|
+
storage.remove(RT_KEY);
|
|
179
|
+
storage.remove(ID_KEY);
|
|
180
|
+
cachedToken = void 0;
|
|
181
|
+
setState({ status: "unauthenticated" });
|
|
182
|
+
const url = new URL(end_session_endpoint);
|
|
183
|
+
const search = new URLSearchParams();
|
|
184
|
+
if (idToken) search.set("id_token_hint", idToken);
|
|
185
|
+
search.set("client_id", options.clientId);
|
|
186
|
+
const postLogout = signOutOptions?.postLogoutRedirectUri;
|
|
187
|
+
if (postLogout) search.set("post_logout_redirect_uri", postLogout);
|
|
188
|
+
url.search = search.toString();
|
|
189
|
+
redirect(url.toString());
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function decodeStoredUser(storage) {
|
|
194
|
+
const idToken = storage.get(ID_KEY);
|
|
195
|
+
return idToken ? userFromIdToken(idToken) : void 0;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Decode (not verify) the ID token to surface profile info in the UI. The
|
|
199
|
+
* token arrives directly from the token endpoint over TLS; RP *backends* must
|
|
200
|
+
* still verify via the server entry point's JWKS check.
|
|
201
|
+
*/
|
|
202
|
+
function userFromIdToken(idToken) {
|
|
203
|
+
const parts = idToken.split(".");
|
|
204
|
+
if (parts.length !== 3) return void 0;
|
|
205
|
+
try {
|
|
206
|
+
const payload = JSON.parse(base64urlDecode(parts[1] ?? ""));
|
|
207
|
+
if (!payload.sub) return void 0;
|
|
208
|
+
if (typeof payload.exp === "number" && payload.exp * 1e3 <= Date.now()) return;
|
|
209
|
+
const user = { sub: payload.sub };
|
|
210
|
+
if (payload.email !== void 0) user.email = payload.email;
|
|
211
|
+
if (payload.email_verified !== void 0) user.emailVerified = payload.email_verified;
|
|
212
|
+
return user;
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function base64urlDecode(input) {
|
|
218
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
219
|
+
return atob(padded);
|
|
220
|
+
}
|
|
221
|
+
async function fetchJson(url) {
|
|
222
|
+
const response = await fetch(url);
|
|
223
|
+
if (!response.ok) throw new Error(`fetch ${url} failed: ${response.status}`);
|
|
224
|
+
return await response.json();
|
|
225
|
+
}
|
|
226
|
+
function currentUrl() {
|
|
227
|
+
return typeof location !== "undefined" ? location.href : "";
|
|
228
|
+
}
|
|
229
|
+
function redirect(url) {
|
|
230
|
+
if (typeof location !== "undefined") location.assign(url);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
export { AuthError, DEFAULT_ISSUER, createAuthClient };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared surface for `@ericminassian/auth`: error type, public user shape,
|
|
4
|
+
* and the default issuer. Import the client, react, or server entry points
|
|
5
|
+
* for actual functionality.
|
|
6
|
+
*/
|
|
7
|
+
declare const DEFAULT_ISSUER = "https://auth.ericminassian.com";
|
|
8
|
+
type AuthErrorCode = "invalid_grant" | "login_required" | "token_refresh_failed" | "state_mismatch" | "network_error" | "invalid_token" | "configuration_error";
|
|
9
|
+
declare class AuthError extends Error {
|
|
10
|
+
readonly code: AuthErrorCode;
|
|
11
|
+
constructor(code: AuthErrorCode, message?: string);
|
|
12
|
+
}
|
|
13
|
+
/** The authenticated subject, derived from the ID token / userinfo. */
|
|
14
|
+
interface User {
|
|
15
|
+
sub: string;
|
|
16
|
+
email?: string;
|
|
17
|
+
emailVerified?: boolean;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { User as i, AuthErrorCode as n, DEFAULT_ISSUER as r, AuthError as t };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { i as User } from "../index-CiPxwNnj.js";
|
|
2
|
+
import { i as SignInOptions, r as AuthState, t as AuthClient } from "../auth-client-DZWvgw3X.js";
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
//#region src/react/context.d.ts
|
|
6
|
+
interface AuthContextValue {
|
|
7
|
+
state: AuthState;
|
|
8
|
+
signIn: (options?: SignInOptions) => Promise<void>;
|
|
9
|
+
signOut: (options?: {
|
|
10
|
+
postLogoutRedirectUri?: string;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
getAccessToken: (options?: {
|
|
13
|
+
forceRefresh?: boolean;
|
|
14
|
+
}) => Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
declare function AuthProvider(props: {
|
|
17
|
+
client: AuthClient;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}): ReactNode;
|
|
20
|
+
declare function useAuth(): AuthContextValue;
|
|
21
|
+
declare function useUser(): User | undefined;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { type AuthContextValue, AuthProvider, useAuth, useUser };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
//#region src/react/context.tsx
|
|
4
|
+
const AuthContext = createContext(void 0);
|
|
5
|
+
function AuthProvider(props) {
|
|
6
|
+
const { client, children } = props;
|
|
7
|
+
const [state, setState] = useState(() => client.getState());
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setState(client.getState());
|
|
10
|
+
return client.onStateChange(setState);
|
|
11
|
+
}, [client]);
|
|
12
|
+
const value = useMemo(() => ({
|
|
13
|
+
state,
|
|
14
|
+
signIn: (options) => client.signInWithRedirect(options),
|
|
15
|
+
signOut: (options) => client.signOut(options),
|
|
16
|
+
getAccessToken: (options) => client.getAccessToken(options)
|
|
17
|
+
}), [client, state]);
|
|
18
|
+
return createElement(AuthContext.Provider, { value }, children);
|
|
19
|
+
}
|
|
20
|
+
function useAuth() {
|
|
21
|
+
const context = useContext(AuthContext);
|
|
22
|
+
if (!context) throw new Error("useAuth must be used within an <AuthProvider>");
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
function useUser() {
|
|
26
|
+
const { state } = useAuth();
|
|
27
|
+
return state.status === "authenticated" ? state.user : void 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
export { AuthProvider, useAuth, useUser };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { r as AuthVerifier, t as AccessTokenClaims } from "../verify-QJxbHc6P.js";
|
|
2
|
+
import { RequestHandler } from "express";
|
|
3
|
+
|
|
4
|
+
//#region src/server/express.d.ts
|
|
5
|
+
declare global {
|
|
6
|
+
namespace Express {
|
|
7
|
+
interface Request {
|
|
8
|
+
auth?: AccessTokenClaims;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Express middleware that verifies the Bearer access token and attaches the
|
|
14
|
+
* claims to `req.auth`. Responds 401 when the token is missing or invalid.
|
|
15
|
+
*/
|
|
16
|
+
declare function requireAuth(verifier: AuthVerifier): RequestHandler;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { requireAuth };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/server/express.ts
|
|
2
|
+
/**
|
|
3
|
+
* Express middleware that verifies the Bearer access token and attaches the
|
|
4
|
+
* claims to `req.auth`. Responds 401 when the token is missing or invalid.
|
|
5
|
+
*/
|
|
6
|
+
function requireAuth(verifier) {
|
|
7
|
+
return (req, res, next) => {
|
|
8
|
+
const header = req.header("authorization");
|
|
9
|
+
const request = new Request("http://local/", { headers: header ? { authorization: header } : {} });
|
|
10
|
+
verifier.authenticateRequest(request).then((result) => {
|
|
11
|
+
if (!result.authenticated) {
|
|
12
|
+
res.status(401).json({ error: "unauthorized" });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
req.auth = result.claims;
|
|
16
|
+
next();
|
|
17
|
+
}).catch(next);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { requireAuth };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { r as AuthVerifier, t as AccessTokenClaims } from "../verify-QJxbHc6P.js";
|
|
2
|
+
import { MiddlewareHandler } from "hono";
|
|
3
|
+
|
|
4
|
+
//#region src/server/hono.d.ts
|
|
5
|
+
declare module "hono" {
|
|
6
|
+
interface ContextVariableMap {
|
|
7
|
+
auth: AccessTokenClaims;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Hono middleware that verifies the Bearer access token and stores the claims
|
|
12
|
+
* at `c.var.auth`. Responds 401 when the token is missing or invalid.
|
|
13
|
+
*/
|
|
14
|
+
declare function authMiddleware(verifier: AuthVerifier): MiddlewareHandler;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { authMiddleware };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/server/hono.ts
|
|
2
|
+
/**
|
|
3
|
+
* Hono middleware that verifies the Bearer access token and stores the claims
|
|
4
|
+
* at `c.var.auth`. Responds 401 when the token is missing or invalid.
|
|
5
|
+
*/
|
|
6
|
+
function authMiddleware(verifier) {
|
|
7
|
+
return async (c, next) => {
|
|
8
|
+
const result = await verifier.authenticateRequest(c.req.raw);
|
|
9
|
+
if (!result.authenticated) return c.json({ error: "unauthorized" }, 401);
|
|
10
|
+
c.set("auth", result.claims);
|
|
11
|
+
await next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
//#endregion
|
|
16
|
+
export { authMiddleware };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { n as AuthErrorCode, t as AuthError } from "../index-CiPxwNnj.js";
|
|
2
|
+
import { a as createAuthVerifier, i as VerifierOptions, n as AuthResult, r as AuthVerifier, t as AccessTokenClaims } from "../verify-QJxbHc6P.js";
|
|
3
|
+
export { type AccessTokenClaims, AuthError, type AuthErrorCode, type AuthResult, type AuthVerifier, type VerifierOptions, createAuthVerifier };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { n as DEFAULT_ISSUER, t as AuthError } from "../src-C0axRlLQ.js";
|
|
2
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
3
|
+
|
|
4
|
+
//#region src/server/verify.ts
|
|
5
|
+
function createAuthVerifier(options) {
|
|
6
|
+
const issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
7
|
+
const clockTolerance = options.clockTolerance ?? "30s";
|
|
8
|
+
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
9
|
+
async function verifyAccessToken(token) {
|
|
10
|
+
try {
|
|
11
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
12
|
+
issuer,
|
|
13
|
+
audience: options.audience,
|
|
14
|
+
clockTolerance,
|
|
15
|
+
typ: "at+jwt",
|
|
16
|
+
algorithms: ["ES256"]
|
|
17
|
+
});
|
|
18
|
+
return payload;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new AuthError("invalid_token", describe(error));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
verifyAccessToken,
|
|
25
|
+
async authenticateRequest(request) {
|
|
26
|
+
const header = request.headers.get("authorization");
|
|
27
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : void 0;
|
|
28
|
+
if (!token) return {
|
|
29
|
+
authenticated: false,
|
|
30
|
+
reason: "missing"
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
return {
|
|
34
|
+
authenticated: true,
|
|
35
|
+
claims: await verifyAccessToken(token)
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
return {
|
|
39
|
+
authenticated: false,
|
|
40
|
+
reason: "invalid"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async verifyLogoutToken(token) {
|
|
45
|
+
try {
|
|
46
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
47
|
+
issuer,
|
|
48
|
+
audience: options.audience,
|
|
49
|
+
clockTolerance,
|
|
50
|
+
typ: "logout+jwt",
|
|
51
|
+
algorithms: ["ES256"]
|
|
52
|
+
});
|
|
53
|
+
if (!payload["events"]?.["http://schemas.openid.net/event/backchannel-logout"]) throw new AuthError("invalid_token", "not a back-channel logout token");
|
|
54
|
+
if ("nonce" in payload) throw new AuthError("invalid_token", "logout token must not contain a nonce");
|
|
55
|
+
const result = {};
|
|
56
|
+
if (typeof payload.sub === "string") result.sub = payload.sub;
|
|
57
|
+
if (typeof payload["sid"] === "string") result.sid = payload["sid"];
|
|
58
|
+
if (result.sub === void 0 && result.sid === void 0) throw new AuthError("invalid_token", "logout token has neither sub nor sid");
|
|
59
|
+
return result;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof AuthError) throw error;
|
|
62
|
+
throw new AuthError("invalid_token", describe(error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function describe(error) {
|
|
68
|
+
return error instanceof Error ? error.message : "token verification failed";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { AuthError, createAuthVerifier };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared surface for `@ericminassian/auth`: error type, public user shape,
|
|
4
|
+
* and the default issuer. Import the client, react, or server entry points
|
|
5
|
+
* for actual functionality.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_ISSUER = "https://auth.ericminassian.com";
|
|
8
|
+
var AuthError = class extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message ?? code);
|
|
12
|
+
this.name = "AuthError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { DEFAULT_ISSUER as n, AuthError as t };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/server/verify.d.ts
|
|
2
|
+
interface VerifierOptions {
|
|
3
|
+
/** Your registered `client_id` — checked against the token `aud`. */
|
|
4
|
+
audience: string;
|
|
5
|
+
/** Defaults to `https://auth.ericminassian.com`. */
|
|
6
|
+
issuer?: string;
|
|
7
|
+
/** Clock skew tolerance, e.g. `"30s"`. Defaults to `"30s"`. */
|
|
8
|
+
clockTolerance?: string;
|
|
9
|
+
}
|
|
10
|
+
interface AccessTokenClaims {
|
|
11
|
+
sub: string;
|
|
12
|
+
sid: string;
|
|
13
|
+
scope: string;
|
|
14
|
+
client_id: string;
|
|
15
|
+
iat: number;
|
|
16
|
+
exp: number;
|
|
17
|
+
jti: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
}
|
|
20
|
+
type AuthResult = {
|
|
21
|
+
authenticated: true;
|
|
22
|
+
claims: AccessTokenClaims;
|
|
23
|
+
} | {
|
|
24
|
+
authenticated: false;
|
|
25
|
+
reason: "missing" | "invalid" | "expired";
|
|
26
|
+
};
|
|
27
|
+
interface AuthVerifier {
|
|
28
|
+
/** Verify a Bearer access token; throws `AuthError` on failure. */
|
|
29
|
+
verifyAccessToken(token: string): Promise<AccessTokenClaims>;
|
|
30
|
+
/** Inspect an HTTP `Request`'s `Authorization: Bearer` header. Never throws. */
|
|
31
|
+
authenticateRequest(request: Request): Promise<AuthResult>;
|
|
32
|
+
/** Verify a back-channel logout token (for an RP's logout receiver). */
|
|
33
|
+
verifyLogoutToken(token: string): Promise<{
|
|
34
|
+
sub?: string;
|
|
35
|
+
sid?: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
declare function createAuthVerifier(options: VerifierOptions): AuthVerifier;
|
|
39
|
+
//#endregion
|
|
40
|
+
export { createAuthVerifier as a, VerifierOptions as i, AuthResult as n, AuthVerifier as r, AccessTokenClaims as t };
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ericminassian/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript SDK for auth.ericminassian.com — OIDC client, React bindings, and server-side JWT verification.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/eric-minassian/auth.git",
|
|
9
|
+
"directory": "packages/auth-sdk"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20.19"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./client": {
|
|
25
|
+
"types": "./dist/client/index.d.ts",
|
|
26
|
+
"default": "./dist/client/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./react": {
|
|
29
|
+
"types": "./dist/react/index.d.ts",
|
|
30
|
+
"default": "./dist/react/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./server": {
|
|
33
|
+
"types": "./dist/server/index.d.ts",
|
|
34
|
+
"default": "./dist/server/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./server/hono": {
|
|
37
|
+
"types": "./dist/server/hono.d.ts",
|
|
38
|
+
"default": "./dist/server/hono.js"
|
|
39
|
+
},
|
|
40
|
+
"./server/express": {
|
|
41
|
+
"types": "./dist/server/express.d.ts",
|
|
42
|
+
"default": "./dist/server/express.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsdown",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"generate": "openapi-typescript ../../openapi/openapi.json -o src/generated/api.d.ts",
|
|
49
|
+
"test": "vitest run"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"jose": "^6"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"react": ">=18",
|
|
56
|
+
"hono": ">=4",
|
|
57
|
+
"express": ">=5"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"react": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"hono": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"express": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/express": "^5.0.0",
|
|
72
|
+
"@types/react": "^19",
|
|
73
|
+
"express": "^5.1.0",
|
|
74
|
+
"hono": "^4.6.0",
|
|
75
|
+
"openapi-typescript": "^7.4.0",
|
|
76
|
+
"react": "^19.0.0",
|
|
77
|
+
"tsdown": "^0.15.0",
|
|
78
|
+
"typescript": "~5.7.0",
|
|
79
|
+
"vitest": "^3.0.0"
|
|
80
|
+
},
|
|
81
|
+
"publishConfig": {
|
|
82
|
+
"access": "public"
|
|
83
|
+
}
|
|
84
|
+
}
|