@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 CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # @selei/auth
2
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.
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;
@@ -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
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ import { createClient } from './index.js';
2
+ export function createNodeClient(config, store) {
3
+ if (!config.clientSecret)
4
+ throw new Error('A server client secret is required.');
5
+ return createClient(config, store, config.clientSecret);
6
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@selei/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }