@selei/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 +88 -2
- package/dist/browser.d.ts +15 -0
- package/dist/browser.js +84 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +76 -0
- package/dist/node.d.ts +13 -0
- package/dist/node.js +6 -0
- package/package.json +46 -3
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @selei/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sign in or sign up with a Seleimend account using OpenID Connect authorization code flow and S256 PKCE. ESM, TypeScript declarations, modern browsers, and Node 20.19+.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm add @selei/auth
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Register an application in the Seleimend administrator console first. Register every callback and logout URL exactly, and the browser application's origin separately. The default issuer is `https://signin.seleimend.com`.
|
|
10
|
+
|
|
11
|
+
## Browser
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createBrowserClient, createSignInButton } from '@selei/auth/browser';
|
|
15
|
+
|
|
16
|
+
const auth = await createBrowserClient({
|
|
17
|
+
clientId: 'YOUR_PUBLIC_CLIENT_ID',
|
|
18
|
+
redirectUri: 'https://your-product.example/callback',
|
|
19
|
+
postLogoutRedirectUri: 'https://your-product.example/',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (location.pathname === '/callback') {
|
|
23
|
+
const user = await auth.handleCallback();
|
|
24
|
+
document.querySelector('#name')!.textContent = user.name ?? user.sub;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
document.querySelector('#signin')!.append(createSignInButton({
|
|
28
|
+
onSignIn: async () => { location.assign(await auth.beginSignIn()); },
|
|
29
|
+
onError: () => { document.querySelector('#error')!.textContent = 'Please try again.'; },
|
|
30
|
+
}));
|
|
31
|
+
// Registration: location.assign(await auth.beginSignUp());
|
|
32
|
+
// Profile: await auth.getUserInfo();
|
|
33
|
+
// Logout: location.assign(await auth.logout());
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Use a **public** client without a secret. The adapter keeps access tokens in memory; reloading starts a new sign-in. Only short-lived PKCE transactions live in sessionStorage, are bound to the tab, and are consumed once. Never put secrets or tokens in localStorage. Browser clients cannot request `offline_access` or refresh tokens. Render profile values as text, never HTML. The consented `picture` URL is temporary; fetch new user information to renew it.
|
|
37
|
+
|
|
38
|
+
## Node
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createNodeClient } from '@selei/auth/node';
|
|
42
|
+
|
|
43
|
+
const auth = await createNodeClient({
|
|
44
|
+
clientId: process.env.SELEIMEND_CLIENT_ID!,
|
|
45
|
+
clientSecret: process.env.SELEIMEND_CLIENT_SECRET!,
|
|
46
|
+
redirectUri: 'https://your-product.example/callback',
|
|
47
|
+
postLogoutRedirectUri: 'https://your-product.example/',
|
|
48
|
+
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
|
49
|
+
}, {
|
|
50
|
+
put: async (state, transaction) => sessionTransactions.put(state, transaction),
|
|
51
|
+
take: async state => sessionTransactions.take(state),
|
|
52
|
+
});
|
|
53
|
+
// Redirect: await auth.beginSignIn() / auth.beginSignUp()
|
|
54
|
+
// Callback: const tokens = await auth.handleCallback(requestAbsoluteUrl)
|
|
55
|
+
// Profile: const user = await auth.getUserInfo(tokens)
|
|
56
|
+
// Refresh: tokens = await auth.refresh(tokens)
|
|
57
|
+
// Revoke: await auth.revoke(tokens.refresh_token ?? tokens.access_token)
|
|
58
|
+
// Logout: redirect(await auth.logout(tokens))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Provide your own transaction store **scoped to the requesting browser session**. `take` must atomically delete and return a transaction. Store tokens only on the server behind a Secure, HttpOnly, SameSite cookie, protect application mutations from CSRF, and persist rotated refresh tokens atomically. Serialize concurrent refreshes: refresh-token reuse revokes the token family. Offline access must be enabled for the confidential client and consented by the user. The absolute refresh lifetime is 30 days.
|
|
62
|
+
|
|
63
|
+
Callbacks validate state, nonce, issuer, audience, signature, expiry, and PKCE. Do not treat decoded JWT data as authorization. Use the stable `sub` identifier for account associations; email addresses can change. Signing out revokes tokens and navigates to the provider's explicit logout confirmation. Other product sessions must check userinfo/introspection or short-lived tokens to observe revocation; there is no back-channel logout endpoint.
|
|
64
|
+
|
|
65
|
+
## Development examples
|
|
66
|
+
|
|
67
|
+
In this repository:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
cd packages/seleimend-auth
|
|
71
|
+
pnpm install --frozen-lockfile
|
|
72
|
+
# Public client callback http://localhost:3001/callback, origin http://localhost:3001
|
|
73
|
+
VITE_SELEIMEND_CLIENT_ID=... pnpm example:browser
|
|
74
|
+
# Confidential callback http://localhost:3000/callback
|
|
75
|
+
SELEIMEND_CLIENT_ID=... SELEIMEND_CLIENT_SECRET=... pnpm example:node
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
See the example source for issuer and port overrides. Local HTTP requires `allowInsecureLocalhost: true`; production requires HTTPS. The Node example uses an in-memory session store for demonstration. Replace it with your durable per-session storage before deployment.
|
|
79
|
+
|
|
80
|
+
## Build and release
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
pnpm install --frozen-lockfile
|
|
84
|
+
pnpm test
|
|
85
|
+
pnpm pack
|
|
86
|
+
pnpm publish --access public
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The registry package is `@selei/auth` and `publishConfig.access` is `public`. pnpm uses existing npm registry credentials; authentication tokens must never enter this package or source control. Only compiled code, declarations, and this README are included. A package archive can be installed locally before its first registry publication.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ClientConfig } from './index.js';
|
|
2
|
+
export type { ClientConfig, TokenSet, UserInfo } from './index.js';
|
|
3
|
+
export declare function createBrowserClient(config: ClientConfig): Promise<{
|
|
4
|
+
beginSignIn: () => Promise<URL>;
|
|
5
|
+
beginSignUp: () => Promise<URL>;
|
|
6
|
+
handleCallback(url?: URL | string): Promise<import("openid-client").UserInfoResponse>;
|
|
7
|
+
getUserInfo(): Promise<import("openid-client").UserInfoResponse>;
|
|
8
|
+
logout(): Promise<URL>;
|
|
9
|
+
revoke(): Promise<void>;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function createSignInButton(options: {
|
|
12
|
+
onSignIn: () => void | Promise<void>;
|
|
13
|
+
label?: string;
|
|
14
|
+
onError?: (error: unknown) => void;
|
|
15
|
+
}): HTMLButtonElement;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createClient } from './index.js';
|
|
2
|
+
export async function createBrowserClient(config) {
|
|
3
|
+
if ('clientSecret' in config)
|
|
4
|
+
throw new Error('Never include a client secret in browser code.');
|
|
5
|
+
const prefix = 'seleimend:' + config.clientId + ':';
|
|
6
|
+
const store = {
|
|
7
|
+
async put(state, transaction) {
|
|
8
|
+
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
|
9
|
+
const key = sessionStorage.key(i);
|
|
10
|
+
if (!key?.startsWith(prefix))
|
|
11
|
+
continue;
|
|
12
|
+
try {
|
|
13
|
+
if (JSON.parse(sessionStorage.getItem(key)).expiresAt <= Date.now())
|
|
14
|
+
sessionStorage.removeItem(key);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
sessionStorage.removeItem(key);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
sessionStorage.setItem(prefix + state, JSON.stringify(transaction));
|
|
21
|
+
},
|
|
22
|
+
async take(state) {
|
|
23
|
+
const data = sessionStorage.getItem(prefix + state);
|
|
24
|
+
sessionStorage.removeItem(prefix + state);
|
|
25
|
+
return data ? JSON.parse(data) : undefined;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const client = await createClient(config, store);
|
|
29
|
+
let tokens;
|
|
30
|
+
return {
|
|
31
|
+
beginSignIn: client.beginSignIn,
|
|
32
|
+
beginSignUp: client.beginSignUp,
|
|
33
|
+
async handleCallback(url = window.location.href) {
|
|
34
|
+
try {
|
|
35
|
+
tokens = await client.handleCallback(url);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
window.history.replaceState(null, '', new URL(config.redirectUri).pathname);
|
|
39
|
+
}
|
|
40
|
+
return client.getUserInfo(tokens);
|
|
41
|
+
},
|
|
42
|
+
async getUserInfo() {
|
|
43
|
+
if (!tokens)
|
|
44
|
+
throw new Error('Sign in first.');
|
|
45
|
+
return client.getUserInfo(tokens);
|
|
46
|
+
},
|
|
47
|
+
async logout() {
|
|
48
|
+
try {
|
|
49
|
+
return await client.logout(tokens);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
tokens = undefined;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async revoke() {
|
|
56
|
+
try {
|
|
57
|
+
if (tokens)
|
|
58
|
+
await client.revoke(tokens.access_token);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
tokens = undefined;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function createSignInButton(options) {
|
|
67
|
+
const button = document.createElement('button');
|
|
68
|
+
button.type = 'button';
|
|
69
|
+
button.className = 'seleimend-signin-button';
|
|
70
|
+
button.textContent = options.label ?? 'Sign in with a Seleimend account';
|
|
71
|
+
button.addEventListener('click', async () => {
|
|
72
|
+
button.disabled = true;
|
|
73
|
+
try {
|
|
74
|
+
await options.onSignIn();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
options.onError?.(error);
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
button.disabled = false;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return button;
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as oidc from 'openid-client';
|
|
2
|
+
export interface ClientConfig {
|
|
3
|
+
issuer?: string;
|
|
4
|
+
clientId: string;
|
|
5
|
+
redirectUri: string;
|
|
6
|
+
postLogoutRedirectUri?: string;
|
|
7
|
+
scopes?: string[];
|
|
8
|
+
/** Explicit opt-in, only for loopback/localhost URLs. */
|
|
9
|
+
allowInsecureLocalhost?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface Transaction {
|
|
12
|
+
verifier: string;
|
|
13
|
+
nonce: string;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
}
|
|
16
|
+
/** Each store belongs to one browser session. take must consume atomically. */
|
|
17
|
+
export interface TransactionStore {
|
|
18
|
+
put(state: string, transaction: Transaction): Promise<void>;
|
|
19
|
+
take(state: string): Promise<Transaction | undefined>;
|
|
20
|
+
}
|
|
21
|
+
export type TokenSet = Awaited<ReturnType<typeof oidc.authorizationCodeGrant>>;
|
|
22
|
+
export type UserInfo = Awaited<ReturnType<typeof oidc.fetchUserInfo>>;
|
|
23
|
+
export declare function createClient(config: ClientConfig, store: TransactionStore, clientSecret?: string): Promise<{
|
|
24
|
+
beginSignIn: () => Promise<URL>;
|
|
25
|
+
beginSignUp: () => Promise<URL>;
|
|
26
|
+
handleCallback: (value: URL | string) => Promise<TokenSet>;
|
|
27
|
+
getUserInfo: (tokens: TokenSet) => Promise<UserInfo>;
|
|
28
|
+
refresh: (tokens: TokenSet) => Promise<TokenSet>;
|
|
29
|
+
revoke: (token: string) => Promise<void>;
|
|
30
|
+
logout: (tokens?: TokenSet) => Promise<URL>;
|
|
31
|
+
}>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as oidc from 'openid-client';
|
|
2
|
+
function safeUrl(value, local) {
|
|
3
|
+
const url = new URL(value);
|
|
4
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.hostname.endsWith('.localhost');
|
|
5
|
+
if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(local && loopback && url.protocol === 'http:'))) {
|
|
6
|
+
throw new Error('Use HTTPS URLs, or explicitly enable localhost HTTP for development.');
|
|
7
|
+
}
|
|
8
|
+
return url;
|
|
9
|
+
}
|
|
10
|
+
export async function createClient(config, store, clientSecret) {
|
|
11
|
+
const local = config.allowInsecureLocalhost === true;
|
|
12
|
+
const issuer = safeUrl(config.issuer ?? 'https://signin.seleimend.com', local);
|
|
13
|
+
const callback = safeUrl(config.redirectUri, local);
|
|
14
|
+
if (config.postLogoutRedirectUri)
|
|
15
|
+
safeUrl(config.postLogoutRedirectUri, local);
|
|
16
|
+
const scopes = config.scopes ?? ['openid', 'profile', 'email'];
|
|
17
|
+
if (!scopes.includes('openid'))
|
|
18
|
+
throw new Error('The openid scope is required.');
|
|
19
|
+
if (!clientSecret && scopes.includes('offline_access'))
|
|
20
|
+
throw new Error('Browser clients cannot request offline access.');
|
|
21
|
+
const configuration = await oidc.discovery(issuer, config.clientId, {}, clientSecret ? oidc.ClientSecretBasic(clientSecret) : oidc.None(), local && issuer.protocol === 'http:' ? { execute: [oidc.allowInsecureRequests] } : undefined);
|
|
22
|
+
oidc.enableNonRepudiationChecks(configuration);
|
|
23
|
+
async function begin(intent = 'signin') {
|
|
24
|
+
const state = oidc.randomState();
|
|
25
|
+
const nonce = oidc.randomNonce();
|
|
26
|
+
const verifier = oidc.randomPKCECodeVerifier();
|
|
27
|
+
await store.put(state, { verifier, nonce, expiresAt: Date.now() + 10 * 60_000 });
|
|
28
|
+
return oidc.buildAuthorizationUrl(configuration, {
|
|
29
|
+
redirect_uri: callback.href, scope: scopes.join(' '), response_type: 'code',
|
|
30
|
+
state, nonce, code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
|
|
31
|
+
code_challenge_method: 'S256', ...(intent === 'signup' ? { prompt: 'create' } : {}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function handleCallback(value) {
|
|
35
|
+
const url = new URL(value);
|
|
36
|
+
if (url.origin !== callback.origin || url.pathname !== callback.pathname)
|
|
37
|
+
throw new Error('Unexpected callback URL.');
|
|
38
|
+
const states = url.searchParams.getAll('state');
|
|
39
|
+
if (states.length !== 1 || !states[0])
|
|
40
|
+
throw new Error('Missing or ambiguous callback state.');
|
|
41
|
+
const transaction = await store.take(states[0]);
|
|
42
|
+
if (!transaction || transaction.expiresAt <= Date.now())
|
|
43
|
+
throw new Error('Sign-in expired or already completed.');
|
|
44
|
+
return oidc.authorizationCodeGrant(configuration, url, {
|
|
45
|
+
expectedState: states[0], expectedNonce: transaction.nonce,
|
|
46
|
+
pkceCodeVerifier: transaction.verifier, idTokenExpected: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
beginSignIn: () => begin('signin'),
|
|
51
|
+
beginSignUp: () => begin('signup'),
|
|
52
|
+
handleCallback,
|
|
53
|
+
getUserInfo: (tokens) => {
|
|
54
|
+
const subject = tokens.claims()?.sub;
|
|
55
|
+
if (!subject)
|
|
56
|
+
throw new Error('An authenticated subject is required.');
|
|
57
|
+
return oidc.fetchUserInfo(configuration, tokens.access_token, subject);
|
|
58
|
+
},
|
|
59
|
+
refresh: async (tokens) => {
|
|
60
|
+
if (!clientSecret || !tokens.refresh_token)
|
|
61
|
+
throw new Error('A server refresh token is required.');
|
|
62
|
+
return oidc.refreshTokenGrant(configuration, tokens.refresh_token);
|
|
63
|
+
},
|
|
64
|
+
revoke: (token) => oidc.tokenRevocation(configuration, token),
|
|
65
|
+
logout: async (tokens) => {
|
|
66
|
+
if (tokens)
|
|
67
|
+
await oidc.tokenRevocation(configuration, tokens.refresh_token ?? tokens.access_token);
|
|
68
|
+
return oidc.buildEndSessionUrl(configuration, {
|
|
69
|
+
client_id: config.clientId,
|
|
70
|
+
// Revocation may remove the provider's ID-token record. Client ID plus an
|
|
71
|
+
// exact registered return URL starts the provider's explicit confirmation.
|
|
72
|
+
...(config.postLogoutRedirectUri ? { post_logout_redirect_uri: config.postLogoutRedirectUri } : {}),
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ClientConfig, type TransactionStore } from './index.js';
|
|
2
|
+
export type { ClientConfig, TransactionStore, Transaction, TokenSet, UserInfo } from './index.js';
|
|
3
|
+
export declare function createNodeClient(config: ClientConfig & {
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
}, store: TransactionStore): Promise<{
|
|
6
|
+
beginSignIn: () => Promise<URL>;
|
|
7
|
+
beginSignUp: () => Promise<URL>;
|
|
8
|
+
handleCallback: (value: URL | string) => Promise<import("./index.js").TokenSet>;
|
|
9
|
+
getUserInfo: (tokens: import("./index.js").TokenSet) => Promise<import("./index.js").UserInfo>;
|
|
10
|
+
refresh: (tokens: import("./index.js").TokenSet) => Promise<import("./index.js").TokenSet>;
|
|
11
|
+
revoke: (token: string) => Promise<void>;
|
|
12
|
+
logout: (tokens?: import("./index.js").TokenSet) => Promise<URL>;
|
|
13
|
+
}>;
|
package/dist/node.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selei/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign in and sign up with a Seleimend account using OpenID Connect.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.19.0"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./browser": {
|
|
19
|
+
"types": "./dist/browser.d.ts",
|
|
20
|
+
"default": "./dist/browser.js"
|
|
21
|
+
},
|
|
22
|
+
"./node": {
|
|
23
|
+
"types": "./dist/node.d.ts",
|
|
24
|
+
"default": "./dist/node.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"openid-client": "6.8.8"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "7.0.2",
|
|
32
|
+
"vite": "8.3.1"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/victorseleimend293/account-manager.git",
|
|
40
|
+
"directory": "packages/seleimend-auth"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/victorseleimend293/account-manager/tree/main/packages/seleimend-auth",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"test": "pnpm build && node --test test/*.test.mjs",
|
|
46
|
+
"example:node": "pnpm build && node examples/node.mjs",
|
|
47
|
+
"example:browser": "pnpm build && vite examples/browser --host 127.0.0.1 --port 3001"
|
|
48
|
+
}
|
|
6
49
|
}
|