@sigrun/oauth 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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-24
10
+ ### Added
11
+ - OIDC login providers with discovery, authorization code, PKCE S256, nonce, and issuer/audience/expiry/JWKS-signature validation.
12
+ - OAuth 2 explicit endpoints and field-path mapping, plus GitHub and Discord presets.
13
+ - Per-provider settings validation, scoped client-secret reads, and provisioning-policy contributions.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,55 @@
1
- # Temporary Holding Version
1
+ Sign in to sigrun with an OIDC or OAuth 2 provider. This is for logging in; MCP account connections live in the MCP extension.
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
+ # @sigrun/oauth
4
+
5
+ The `oauth` extension contributes `loginProviders` to sigrun's login seam. Core qualifies them as `login:oauth:<id>`. It supports OIDC discovery with authorization code, PKCE S256, nonce, and full ID-token validation; GitHub and Discord presets; and explicit OAuth 2 endpoints with configurable userinfo field paths.
6
+
7
+ ## Settings
8
+
9
+ Configure `settings/oauth.json` through sigrun's extension settings surface:
10
+
11
+ ```json
12
+ {
13
+ "providers": [
14
+ {
15
+ "id": "company",
16
+ "label": "Company SSO",
17
+ "kind": "oidc",
18
+ "issuer": "https://id.example.com",
19
+ "clientId": "sigrun",
20
+ "scopes": "openid profile email",
21
+ "usernameClaim": "preferred_username",
22
+ "groupsClaim": "groups",
23
+ "autoProvision": false,
24
+ "allowedGroups": ["staff"],
25
+ "allowedDomains": ["example.com"]
26
+ },
27
+ {
28
+ "id": "github",
29
+ "label": "GitHub",
30
+ "kind": "oauth2",
31
+ "preset": "github",
32
+ "clientId": "your-client-id"
33
+ }
34
+ ]
35
+ }
36
+ ```
37
+
38
+ Set each client secret in the extension secret store under `<id>.clientSecret` (for example, `github.clientSecret`). Never put a client secret in settings. A client ID is public configuration and is required in settings on every provider form, including both OAuth 2 forms.
39
+
40
+ An OAuth 2 provider may instead declare `authorizeUrl`, `tokenUrl`, and `userinfoUrl`, plus `fields` with a required `subject` and optional `username`, `name`, `email`, `emailVerified`, and `groups` dot-separated field paths. Optional `scopes` default to `openid profile email`. The `policy` fields (`autoProvision`, `allowedGroups`, `allowedDomains`) are reported to core, which enforces them. GitHub email is selected only from the primary, verified entry in `/user/emails`; Discord uses its user `verified` field. Neither preset supplies groups.
41
+
42
+ Malformed entries are skipped individually with a warning naming the provider. With no valid providers, the extension returns `loginProviders: []`. Production provider endpoints require HTTPS; plain HTTP is limited to loopback URLs for local development and tests.
43
+
44
+ ## Development
45
+
46
+ ```sh
47
+ npm install --legacy-peer-deps --ignore-scripts
48
+ npm test
49
+ npm run typecheck
50
+ npm run build
51
+ ```
52
+
53
+ `dist/` is committed because git-source extension installs load it directly. Rebuild and commit it with source changes.
54
+
55
+ Core's types-only contract is currently mapped in `tsconfig.json` to the M3b core worktree's built declaration. This is a development-only coupling; use a registry development dependency after the matching core contract is published. Do not publish this package from that worktree-specific state.
@@ -0,0 +1,49 @@
1
+ import type { LoginProviderContribution } from "@sigrun/sigrun/extension";
2
+ export declare const DEFAULT_SCOPES = "openid profile email";
3
+ export declare const DEFAULT_USERNAME_CLAIM = "preferred_username";
4
+ export declare const DEFAULT_GROUPS_CLAIM = "groups";
5
+ export interface ProvisioningPolicy {
6
+ autoProvision?: boolean;
7
+ allowedGroups?: string[];
8
+ allowedDomains?: string[];
9
+ }
10
+ interface Common {
11
+ id: string;
12
+ label: string;
13
+ icon?: string;
14
+ policy?: ProvisioningPolicy;
15
+ }
16
+ export interface OidcConfig extends Common {
17
+ kind: "oidc";
18
+ issuer: string;
19
+ clientId: string;
20
+ scopes: string;
21
+ usernameClaim: string;
22
+ groupsClaim: string;
23
+ }
24
+ export interface FieldMap {
25
+ subject: string;
26
+ username?: string;
27
+ name?: string;
28
+ email?: string;
29
+ emailVerified?: string;
30
+ groups?: string;
31
+ }
32
+ export interface Oauth2Config extends Common {
33
+ kind: "oauth2";
34
+ clientId: string;
35
+ authorizeUrl: string;
36
+ tokenUrl: string;
37
+ userinfoUrl: string;
38
+ scopes: string;
39
+ fields: FieldMap;
40
+ preset?: "github" | "discord";
41
+ }
42
+ export type ProviderConfig = (OidcConfig | Oauth2Config) & {
43
+ policy?: ProvisioningPolicy;
44
+ };
45
+ export type Warning = (message: string) => void;
46
+ export declare function parseProvider(value: unknown): ProviderConfig;
47
+ export declare function parseProviders(settings: Record<string, unknown>, warn?: Warning): ProviderConfig[];
48
+ export declare function contributionPolicy(config: ProviderConfig): LoginProviderContribution["policy"];
49
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,141 @@
1
+ import { githubPreset, discordPreset } from "./presets.js";
2
+ export const DEFAULT_SCOPES = "openid profile email";
3
+ export const DEFAULT_USERNAME_CLAIM = "preferred_username";
4
+ export const DEFAULT_GROUPS_CLAIM = "groups";
5
+ function object(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function string(entry, key, required = true) {
9
+ const value = entry[key];
10
+ if (value === undefined && !required)
11
+ return undefined;
12
+ if (typeof value !== "string" || value.length === 0)
13
+ throw new Error(`${key} must be a non-empty string`);
14
+ return value;
15
+ }
16
+ function url(entry, key) {
17
+ const value = string(entry, key);
18
+ try {
19
+ const parsed = new URL(value);
20
+ const loopback = ["127.0.0.1", "localhost", "::1"].includes(parsed.hostname);
21
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback))
22
+ throw new Error();
23
+ return value;
24
+ }
25
+ catch {
26
+ throw new Error(`${key} must be an HTTP(S) URL`);
27
+ }
28
+ }
29
+ function strings(value, key) {
30
+ if (value === undefined)
31
+ return undefined;
32
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0))
33
+ throw new Error(`${key} must be an array of non-empty strings`);
34
+ return value;
35
+ }
36
+ function policy(entry) {
37
+ const result = {};
38
+ if (entry.autoProvision !== undefined) {
39
+ if (typeof entry.autoProvision !== "boolean")
40
+ throw new Error("autoProvision must be boolean");
41
+ result.autoProvision = entry.autoProvision;
42
+ }
43
+ result.allowedGroups = strings(entry.allowedGroups, "allowedGroups");
44
+ result.allowedDomains = strings(entry.allowedDomains, "allowedDomains");
45
+ return Object.keys(result).length === 0 ? undefined : result;
46
+ }
47
+ function common(entry) {
48
+ const id = string(entry, "id");
49
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id))
50
+ throw new Error("id is invalid");
51
+ return {
52
+ id,
53
+ label: string(entry, "label"),
54
+ ...(entry.icon === undefined ? {} : { icon: string(entry, "icon") }),
55
+ ...(policy(entry) === undefined ? {} : { policy: policy(entry) }),
56
+ };
57
+ }
58
+ function scopes(entry) {
59
+ return string(entry, "scopes", false) ?? DEFAULT_SCOPES;
60
+ }
61
+ function fields(value) {
62
+ if (!object(value))
63
+ throw new Error("fields must be an object");
64
+ const result = { subject: string(value, "subject") };
65
+ for (const key of ["username", "name", "email", "emailVerified", "groups"]) {
66
+ const field = string(value, key, false);
67
+ if (field !== undefined)
68
+ result[key] = field;
69
+ }
70
+ return result;
71
+ }
72
+ export function parseProvider(value) {
73
+ if (!object(value))
74
+ throw new Error("provider must be an object");
75
+ const base = common(value);
76
+ const clientId = string(value, "clientId");
77
+ if (value.kind === "oidc") {
78
+ return {
79
+ ...base,
80
+ kind: "oidc",
81
+ clientId,
82
+ issuer: url(value, "issuer").replace(/\/$/, ""),
83
+ scopes: scopes(value),
84
+ usernameClaim: string(value, "usernameClaim", false) ?? DEFAULT_USERNAME_CLAIM,
85
+ groupsClaim: string(value, "groupsClaim", false) ?? DEFAULT_GROUPS_CLAIM,
86
+ };
87
+ }
88
+ if (value.kind !== "oauth2")
89
+ throw new Error("kind must be oidc or oauth2");
90
+ let endpoints;
91
+ let preset;
92
+ if (value.preset !== undefined) {
93
+ if (value.preset !== "github" && value.preset !== "discord")
94
+ throw new Error("unknown oauth2 preset");
95
+ preset = value.preset;
96
+ endpoints = { ...(preset === "github" ? githubPreset() : discordPreset()) };
97
+ }
98
+ else {
99
+ endpoints = value;
100
+ }
101
+ return {
102
+ ...base,
103
+ kind: "oauth2",
104
+ clientId,
105
+ authorizeUrl: url(endpoints, "authorizeUrl"),
106
+ tokenUrl: url(endpoints, "tokenUrl"),
107
+ userinfoUrl: url(endpoints, "userinfoUrl"),
108
+ scopes: scopes({ scopes: value.scopes ?? endpoints.scopes }),
109
+ fields: fields(value.fields ?? endpoints.fields),
110
+ ...(preset === undefined ? {} : { preset }),
111
+ };
112
+ }
113
+ export function parseProviders(settings, warn = console.warn) {
114
+ if (settings.providers === undefined)
115
+ return [];
116
+ if (!Array.isArray(settings.providers)) {
117
+ warn("oauth: settings providers is not an array; no login providers loaded");
118
+ return [];
119
+ }
120
+ const providers = [];
121
+ const ids = new Set();
122
+ for (const value of settings.providers) {
123
+ const name = object(value) && typeof value.id === "string" ? value.id : "unnamed";
124
+ try {
125
+ const provider = parseProvider(value);
126
+ if (ids.has(provider.id)) {
127
+ warn(`oauth: skipping duplicate provider "${provider.id}"`);
128
+ continue;
129
+ }
130
+ ids.add(provider.id);
131
+ providers.push(provider);
132
+ }
133
+ catch (error) {
134
+ warn(`oauth: skipping malformed provider "${name}": ${error instanceof Error ? error.message : String(error)}`);
135
+ }
136
+ }
137
+ return providers;
138
+ }
139
+ export function contributionPolicy(config) {
140
+ return config.policy;
141
+ }
@@ -0,0 +1,10 @@
1
+ import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
2
+ export declare const EXTENSION_NAME = "oauth";
3
+ /** Build login providers from this extension's scoped settings and secrets. */
4
+ export declare function createOAuth(host: SigrunHost): Promise<SigrunExtensionResult>;
5
+ export default createOAuth;
6
+ export { parseProviders, parseProvider } from "./config.js";
7
+ export type { ProviderConfig, OidcConfig, Oauth2Config, FieldMap } from "./config.js";
8
+ export { createOidcProvider } from "./oidc.js";
9
+ export { createOauth2Provider } from "./oauth2.js";
10
+ export { githubPreset, discordPreset } from "./presets.js";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ import { parseProviders } from "./config.js";
2
+ import { createOidcProvider } from "./oidc.js";
3
+ import { createOauth2Provider } from "./oauth2.js";
4
+ export const EXTENSION_NAME = "oauth";
5
+ /** Build login providers from this extension's scoped settings and secrets. */
6
+ export async function createOAuth(host) {
7
+ const settings = await host.stores.settings.get();
8
+ const loginProviders = parseProviders(settings).map((config) => {
9
+ const readSecret = () => host.stores.secrets.get(`${config.id}.clientSecret`);
10
+ return config.kind === "oidc"
11
+ ? createOidcProvider(config, readSecret)
12
+ : createOauth2Provider(config, readSecret);
13
+ });
14
+ return { name: EXTENSION_NAME, loginProviders };
15
+ }
16
+ export default createOAuth;
17
+ export { parseProviders, parseProvider } from "./config.js";
18
+ export { createOidcProvider } from "./oidc.js";
19
+ export { createOauth2Provider } from "./oauth2.js";
20
+ export { githubPreset, discordPreset } from "./presets.js";
@@ -0,0 +1,11 @@
1
+ import type { HandlerContext, LoginFlow, LoginProviderContribution, LoginResult } from "@sigrun/sigrun/extension";
2
+ import type { Oauth2Config } from "./config.js";
3
+ import type { Fetch, SecretReader } from "./oidc.js";
4
+ export declare function mapUserinfo(config: Oauth2Config, userinfo: unknown): LoginResult;
5
+ export declare function beginOauth2(config: Oauth2Config, flow: LoginFlow): Promise<{
6
+ redirectUrl: string;
7
+ }>;
8
+ export declare function completeOauth2(config: Oauth2Config, flow: LoginFlow, callback: {
9
+ query: Record<string, string>;
10
+ }, ctx: HandlerContext, secret: string | undefined, fetchImpl: Fetch): Promise<LoginResult>;
11
+ export declare function createOauth2Provider(config: Oauth2Config, readSecret: SecretReader, fetchImpl?: Fetch): LoginProviderContribution;
package/dist/oauth2.js ADDED
@@ -0,0 +1,113 @@
1
+ import * as oauth from "oauth4webapi";
2
+ function field(value, path) {
3
+ let current = value;
4
+ for (const part of path.split(".")) {
5
+ if (typeof current !== "object" || current === null || Array.isArray(current))
6
+ return undefined;
7
+ current = current[part];
8
+ }
9
+ return current;
10
+ }
11
+ function stringField(value, path) {
12
+ if (path === undefined)
13
+ return undefined;
14
+ const result = field(value, path);
15
+ if (result === undefined || result === null)
16
+ return undefined;
17
+ if (typeof result === "string")
18
+ return result;
19
+ if (typeof result === "number" && Number.isFinite(result))
20
+ return String(result);
21
+ throw new Error(`OAuth 2 userinfo field ${path} is not a string or number`);
22
+ }
23
+ function booleanField(value, path) {
24
+ if (path === undefined)
25
+ return undefined;
26
+ const result = field(value, path);
27
+ if (result === undefined)
28
+ return undefined;
29
+ if (typeof result !== "boolean")
30
+ throw new Error(`OAuth 2 userinfo field ${path} is not boolean`);
31
+ return result;
32
+ }
33
+ export function mapUserinfo(config, userinfo) {
34
+ const subject = stringField(userinfo, config.fields.subject);
35
+ if (subject === undefined || subject === "")
36
+ throw new Error("OAuth 2 userinfo subject is missing");
37
+ const groups = config.fields.groups === undefined ? undefined : field(userinfo, config.fields.groups);
38
+ if (groups !== undefined && (!Array.isArray(groups) || groups.some((group) => typeof group !== "string")))
39
+ throw new Error("OAuth 2 groups field is not a string array");
40
+ return {
41
+ subject,
42
+ profile: {
43
+ ...(stringField(userinfo, config.fields.username) === undefined ? {} : { username: stringField(userinfo, config.fields.username) }),
44
+ ...(stringField(userinfo, config.fields.name) === undefined ? {} : { name: stringField(userinfo, config.fields.name) }),
45
+ ...(stringField(userinfo, config.fields.email) === undefined ? {} : { email: stringField(userinfo, config.fields.email) }),
46
+ ...(booleanField(userinfo, config.fields.emailVerified) === undefined ? {} : { emailVerified: booleanField(userinfo, config.fields.emailVerified) }),
47
+ },
48
+ ...(groups === undefined ? {} : { groups: groups }),
49
+ };
50
+ }
51
+ async function githubEmail(accessToken, fetchImpl, signal) {
52
+ const response = await fetchImpl("https://api.github.com/user/emails", {
53
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/vnd.github+json" },
54
+ signal,
55
+ });
56
+ if (!response.ok)
57
+ throw new Error(`GitHub email lookup failed (${response.status})`);
58
+ const entries = await response.json();
59
+ if (!Array.isArray(entries))
60
+ throw new Error("GitHub email response is not an array");
61
+ const primary = entries.find((entry) => typeof entry === "object" && entry !== null && entry.primary === true && entry.verified === true);
62
+ if (primary === undefined)
63
+ return { emailVerified: false };
64
+ const email = primary.email;
65
+ if (typeof email !== "string")
66
+ throw new Error("GitHub primary verified email is not a string");
67
+ return { email, emailVerified: true };
68
+ }
69
+ export async function beginOauth2(config, flow) {
70
+ const verifier = oauth.generateRandomCodeVerifier();
71
+ flow.data.codeVerifier = verifier;
72
+ const url = new URL(config.authorizeUrl);
73
+ url.searchParams.set("client_id", config.clientId);
74
+ url.searchParams.set("redirect_uri", flow.callbackUrl);
75
+ url.searchParams.set("response_type", "code");
76
+ url.searchParams.set("scope", config.scopes);
77
+ url.searchParams.set("state", flow.state);
78
+ url.searchParams.set("code_challenge", await oauth.calculatePKCECodeChallenge(verifier));
79
+ url.searchParams.set("code_challenge_method", "S256");
80
+ return { redirectUrl: url.toString() };
81
+ }
82
+ export async function completeOauth2(config, flow, callback, ctx, secret, fetchImpl) {
83
+ if (secret === undefined || secret === "")
84
+ throw new Error(`OAuth 2 provider "${config.id}" is missing its client secret`);
85
+ if (flow.data.codeVerifier === undefined)
86
+ throw new Error("OAuth 2 login flow is missing PKCE state");
87
+ const as = { issuer: config.authorizeUrl, token_endpoint: config.tokenUrl };
88
+ const client = { client_id: config.clientId };
89
+ const parameters = oauth.validateAuthResponse(as, client, new URLSearchParams(callback.query), flow.state);
90
+ const response = await oauth.authorizationCodeGrantRequest(as, client, oauth.ClientSecretPost(secret), parameters, flow.callbackUrl, flow.data.codeVerifier, { [oauth.customFetch]: fetchImpl, signal: ctx.abortSignal });
91
+ const token = await oauth.processAuthorizationCodeResponse(as, client, response);
92
+ const accessToken = token.access_token;
93
+ if (typeof accessToken !== "string")
94
+ throw new Error("OAuth 2 token response has no access token");
95
+ const userinfoResponse = await fetchImpl(config.userinfoUrl, { headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }, signal: ctx.abortSignal });
96
+ if (!userinfoResponse.ok)
97
+ throw new Error(`OAuth 2 userinfo request failed (${userinfoResponse.status})`);
98
+ const result = mapUserinfo(config, await userinfoResponse.json());
99
+ if (config.preset === "github" || config.userinfoUrl === "https://api.github.com/user") {
100
+ return { ...result, profile: { ...result.profile, ...(await githubEmail(accessToken, fetchImpl, ctx.abortSignal)) } };
101
+ }
102
+ return result;
103
+ }
104
+ export function createOauth2Provider(config, readSecret, fetchImpl = fetch) {
105
+ return {
106
+ id: config.id,
107
+ label: config.label,
108
+ ...(config.icon === undefined ? {} : { icon: config.icon }),
109
+ ...(config.policy === undefined ? {} : { policy: config.policy }),
110
+ begin: (flow) => beginOauth2(config, flow),
111
+ complete: async (flow, callback, ctx) => completeOauth2(config, flow, callback, ctx, await readSecret(), fetchImpl),
112
+ };
113
+ }
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import * as oauth from "oauth4webapi";
2
+ import type { HandlerContext, LoginFlow, LoginProviderContribution, LoginResult } from "@sigrun/sigrun/extension";
3
+ import type { OidcConfig } from "./config.js";
4
+ export type Fetch = typeof fetch;
5
+ export type SecretReader = () => Promise<string | undefined>;
6
+ export declare function discover(issuer: string, fetchImpl: Fetch, ctx: HandlerContext): Promise<oauth.AuthorizationServer>;
7
+ export declare function beginOidc(config: OidcConfig, flow: LoginFlow, ctx: HandlerContext, fetchImpl: Fetch): Promise<{
8
+ redirectUrl: string;
9
+ }>;
10
+ export declare function oidcResult(config: OidcConfig, claims: oauth.IDToken): LoginResult;
11
+ export declare function completeOidc(config: OidcConfig, flow: LoginFlow, callback: {
12
+ query: Record<string, string>;
13
+ body?: unknown;
14
+ }, ctx: HandlerContext, secret: string | undefined, fetchImpl: Fetch): Promise<LoginResult>;
15
+ export declare function createOidcProvider(config: OidcConfig, readSecret: SecretReader, fetchImpl?: Fetch): LoginProviderContribution;
package/dist/oidc.js ADDED
@@ -0,0 +1,103 @@
1
+ import * as oauth from "oauth4webapi";
2
+ function requestOptions(fetchImpl, ctx) {
3
+ return { [oauth.customFetch]: fetchImpl, signal: ctx.abortSignal };
4
+ }
5
+ export async function discover(issuer, fetchImpl, ctx) {
6
+ const identifier = new URL(issuer);
7
+ return oauth.processDiscoveryResponse(identifier, await oauth.discoveryRequest(identifier, requestOptions(fetchImpl, ctx)));
8
+ }
9
+ export async function beginOidc(config, flow, ctx, fetchImpl) {
10
+ const as = await discover(config.issuer, fetchImpl, ctx);
11
+ if (as.authorization_endpoint === undefined) {
12
+ throw new Error(`OIDC provider "${config.id}" has no authorization endpoint`);
13
+ }
14
+ const verifier = oauth.generateRandomCodeVerifier();
15
+ const nonce = oauth.generateRandomNonce();
16
+ flow.data.codeVerifier = verifier;
17
+ flow.data.nonce = nonce;
18
+ const url = new URL(as.authorization_endpoint);
19
+ url.searchParams.set("client_id", config.clientId);
20
+ url.searchParams.set("redirect_uri", flow.callbackUrl);
21
+ url.searchParams.set("response_type", "code");
22
+ url.searchParams.set("scope", config.scopes);
23
+ url.searchParams.set("state", flow.state);
24
+ url.searchParams.set("code_challenge", await oauth.calculatePKCECodeChallenge(verifier));
25
+ url.searchParams.set("code_challenge_method", "S256");
26
+ url.searchParams.set("nonce", nonce);
27
+ return { redirectUrl: url.toString() };
28
+ }
29
+ function stringClaim(claims, name) {
30
+ const value = claims[name];
31
+ if (value === undefined)
32
+ return undefined;
33
+ if (typeof value !== "string")
34
+ throw new Error(`ID token claim ${name} is not a string`);
35
+ return value;
36
+ }
37
+ function groupsClaim(claims, name) {
38
+ const value = claims[name];
39
+ if (value === undefined)
40
+ return undefined;
41
+ if (!Array.isArray(value) || value.some((group) => typeof group !== "string")) {
42
+ throw new Error(`ID token claim ${name} is not a string array`);
43
+ }
44
+ return value;
45
+ }
46
+ export function oidcResult(config, claims) {
47
+ const username = stringClaim(claims, config.usernameClaim);
48
+ const name = stringClaim(claims, "name");
49
+ const email = stringClaim(claims, "email");
50
+ const emailVerified = claims.email_verified;
51
+ const groups = groupsClaim(claims, config.groupsClaim);
52
+ if (emailVerified !== undefined && typeof emailVerified !== "boolean") {
53
+ throw new Error("ID token claim email_verified is not boolean");
54
+ }
55
+ return {
56
+ subject: stringClaim(claims, "sub"),
57
+ profile: {
58
+ ...(username === undefined ? {} : { username }),
59
+ ...(name === undefined ? {} : { name }),
60
+ ...(email === undefined ? {} : { email }),
61
+ ...(emailVerified === undefined ? {} : { emailVerified }),
62
+ },
63
+ ...(groups === undefined ? {} : { groups }),
64
+ };
65
+ }
66
+ export async function completeOidc(config, flow, callback, ctx, secret, fetchImpl) {
67
+ if (secret === undefined || secret === "") {
68
+ throw new Error(`OIDC provider "${config.id}" is missing its client secret`);
69
+ }
70
+ if (flow.data.codeVerifier === undefined || flow.data.nonce === undefined) {
71
+ throw new Error("OIDC login flow is missing PKCE or nonce state");
72
+ }
73
+ const as = await discover(config.issuer, fetchImpl, ctx);
74
+ if (as.jwks_uri === undefined) {
75
+ throw new Error(`OIDC provider "${config.id}" has no JWKS endpoint`);
76
+ }
77
+ const client = { client_id: config.clientId };
78
+ const parameters = oauth.validateAuthResponse(as, client, new URLSearchParams(callback.query), flow.state);
79
+ const response = await oauth.authorizationCodeGrantRequest(as, client, oauth.ClientSecretPost(secret), parameters, flow.callbackUrl, flow.data.codeVerifier, requestOptions(fetchImpl, ctx));
80
+ // oauth4webapi validates issuer, audience, expiry and the expected nonce.
81
+ // Explicit JWKS validation below also requires a valid ID-token signature.
82
+ const result = await oauth.processAuthorizationCodeResponse(as, client, response, {
83
+ expectedNonce: flow.data.nonce,
84
+ requireIdToken: true,
85
+ ...requestOptions(fetchImpl, ctx),
86
+ });
87
+ const claims = oauth.getValidatedIdTokenClaims(result);
88
+ if (claims === undefined || result.id_token === undefined) {
89
+ throw new Error("OIDC token response has no ID token");
90
+ }
91
+ await oauth.validateApplicationLevelSignature(as, response, requestOptions(fetchImpl, ctx));
92
+ return oidcResult(config, claims);
93
+ }
94
+ export function createOidcProvider(config, readSecret, fetchImpl = fetch) {
95
+ return {
96
+ id: config.id,
97
+ label: config.label,
98
+ ...(config.icon === undefined ? {} : { icon: config.icon }),
99
+ ...(config.policy === undefined ? {} : { policy: config.policy }),
100
+ begin: (flow, ctx) => beginOidc(config, flow, ctx, fetchImpl),
101
+ complete: async (flow, callback, ctx) => completeOidc(config, flow, callback, ctx, await readSecret(), fetchImpl),
102
+ };
103
+ }
@@ -0,0 +1,10 @@
1
+ import type { FieldMap } from "./config.js";
2
+ export interface Preset {
3
+ authorizeUrl: string;
4
+ tokenUrl: string;
5
+ userinfoUrl: string;
6
+ scopes: string;
7
+ fields: FieldMap;
8
+ }
9
+ export declare function githubPreset(): Preset;
10
+ export declare function discordPreset(): Preset;
@@ -0,0 +1,18 @@
1
+ export function githubPreset() {
2
+ return {
3
+ authorizeUrl: "https://github.com/login/oauth/authorize",
4
+ tokenUrl: "https://github.com/login/oauth/access_token",
5
+ userinfoUrl: "https://api.github.com/user",
6
+ scopes: "read:user user:email",
7
+ fields: { subject: "id", username: "login", name: "name" },
8
+ };
9
+ }
10
+ export function discordPreset() {
11
+ return {
12
+ authorizeUrl: "https://discord.com/oauth2/authorize",
13
+ tokenUrl: "https://discord.com/api/oauth2/token",
14
+ userinfoUrl: "https://discord.com/api/users/@me",
15
+ scopes: "identify email",
16
+ fields: { subject: "id", username: "username", email: "email", emailVerified: "verified" },
17
+ };
18
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
- "name": "@sigrun/oauth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@sigrun/oauth",
3
+ "version": "0.1.0",
4
+ "description": "OIDC and OAuth 2 login providers for sigrun",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.19.0"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "test": "node scripts/check-manifest.mjs --expect-name @sigrun/oauth && vitest run",
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "oauth4webapi": "3.8.8"
29
+ },
30
+ "peerDependencies": {
31
+ "@sigrun/sigrun": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^26.6.4",
35
+ "jose": "^6.2.13",
36
+ "tsx": "^4.23.15",
37
+ "typescript": "^7.0.2",
38
+ "vite": "^8.3.5",
39
+ "vitest": "^5.0.3"
40
+ },
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "github",
44
+ "url": "git+https://github.com/sigrun-ai/sigrun-ext-oauth.git"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/sigrun-ai/sigrun-ext-oauth/issues"
48
+ },
49
+ "homepage": "https://github.com/sigrun-ai/sigrun-ext-oauth#readme",
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ }
54
+ }