@ggui-ai/mcp-server 0.1.0-rc.1
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/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- package/package.json +76 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth login routes.
|
|
3
|
+
*
|
|
4
|
+
* Two endpoints per provider:
|
|
5
|
+
*
|
|
6
|
+
* - `GET /ggui/oauth-login/:providerId/start?next=/settings`
|
|
7
|
+
* Mints PKCE verifier + S256 challenge, signs a state token
|
|
8
|
+
* binding `${random}|${providerId}|${nextPath}|${expiresAt}` with
|
|
9
|
+
* HMAC-SHA256, stashes the verifier in a HttpOnly short-lived
|
|
10
|
+
* `ggui_oauth_pkce` cookie, redirects 302 to the provider's
|
|
11
|
+
* authorize URL.
|
|
12
|
+
*
|
|
13
|
+
* - `GET /ggui/oauth-login/:providerId/callback?code=&state=&error=`
|
|
14
|
+
* Validates state HMAC + freshness + provider match, reads the
|
|
15
|
+
* PKCE verifier off the cookie, exchanges the code for the user's
|
|
16
|
+
* `providerSubject`, mints a `ggui_user_*` bearer, registers it
|
|
17
|
+
* with the `AuthAdapter`, sets the `ggui_user_session` cookie
|
|
18
|
+
* atomically, clears the PKCE cookie, redirects 302 to the
|
|
19
|
+
* state-bound `nextPath`.
|
|
20
|
+
*
|
|
21
|
+
* Identity model: callbacks mint
|
|
22
|
+
* `{ kind: 'user', userId: '${providerId}:${providerSubject}', roles: [] }`.
|
|
23
|
+
* Email is informational metadata only — never load-bearing for the
|
|
24
|
+
* identity. See `oauth-login-types.ts` for the locked seam.
|
|
25
|
+
*
|
|
26
|
+
* **Security boundary**: PKCE verifier MUST stay server-bound — the
|
|
27
|
+
* state token is plaintext to the user (only HMAC-signed), so the
|
|
28
|
+
* verifier lives in a separate HttpOnly cookie. State HMAC binds
|
|
29
|
+
* providerId so a state from provider A can't be replayed at provider
|
|
30
|
+
* B. `next` param is validated as same-origin relative path; otherwise
|
|
31
|
+
* defaults to `/settings` (open-redirect rejection).
|
|
32
|
+
*/
|
|
33
|
+
import type { Express } from 'express';
|
|
34
|
+
import type { AuditSink, AuthAdapter } from '@ggui-ai/mcp-server-core';
|
|
35
|
+
import { type OAuthLoginProvider } from './oauth-login-types.js';
|
|
36
|
+
import type { Logger } from './logger.js';
|
|
37
|
+
export declare const DEFAULT_OAUTH_START_PATH = "/ggui/oauth-login/:providerId/start";
|
|
38
|
+
export declare const DEFAULT_OAUTH_CALLBACK_PATH = "/ggui/oauth-login/:providerId/callback";
|
|
39
|
+
/**
|
|
40
|
+
* Public route exposing the currently-configured provider list as
|
|
41
|
+
* `[{providerId, displayName}]`. Anonymous-readable on purpose — the
|
|
42
|
+
* `/login` page fetches this to render only buttons for providers
|
|
43
|
+
* the operator has actually wired up. Returns `[]` when no provider
|
|
44
|
+
* is enabled or credentialed; never leaks `clientSecret`.
|
|
45
|
+
*/
|
|
46
|
+
export declare const DEFAULT_OAUTH_PROVIDERS_LIST_PATH = "/ggui/oauth-login/providers";
|
|
47
|
+
export declare const OAUTH_PKCE_COOKIE_NAME = "ggui_oauth_pkce";
|
|
48
|
+
export interface OAuthLoginRoutesOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Live provider list. May be a static array (snapshot at mount
|
|
51
|
+
* time — operator restart to apply changes) OR a getter the routes
|
|
52
|
+
* call per request (dynamic — admin paste-then-click works without
|
|
53
|
+
* restart). The CLI passes a getter wired through the
|
|
54
|
+
* OAuthProvidersStore so credentials saved at /admin/oauth-providers
|
|
55
|
+
* are picked up on the next /start request.
|
|
56
|
+
*/
|
|
57
|
+
readonly providers: ReadonlyArray<OAuthLoginProvider> | (() => ReadonlyArray<OAuthLoginProvider> | Promise<ReadonlyArray<OAuthLoginProvider>>);
|
|
58
|
+
readonly auth: AuthAdapter;
|
|
59
|
+
readonly logger: Logger;
|
|
60
|
+
/** HMAC secret for state-token signing. ≥32 bytes recommended. */
|
|
61
|
+
readonly stateSecret: string;
|
|
62
|
+
/** Public base URL the server is reachable at; used to compose `redirect_uri`. */
|
|
63
|
+
readonly publicBaseUrl: string;
|
|
64
|
+
/** Optional audit sink — fires `auth.oauth.start/.success/.failure`. */
|
|
65
|
+
readonly auditSink?: AuditSink;
|
|
66
|
+
/** Adds `Secure` to cookies. */
|
|
67
|
+
readonly secure?: boolean;
|
|
68
|
+
/** User-session cookie TTL (seconds). */
|
|
69
|
+
readonly ttlSec?: number;
|
|
70
|
+
/** Override paths (mostly for tests). */
|
|
71
|
+
readonly startPath?: string;
|
|
72
|
+
readonly callbackPath?: string;
|
|
73
|
+
readonly providersListPath?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Mount the OAuth login routes onto an Express app. */
|
|
76
|
+
export declare function mountOAuthLoginRoutes(app: Express, opts: OAuthLoginRoutesOptions): void;
|
|
77
|
+
//# sourceMappingURL=oauth-login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth-login.d.ts","sourceRoot":"","sources":["../src/oauth-login.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,KAAK,EAAE,OAAO,EAAqB,MAAM,SAAS,CAAC;AAO1D,OAAO,KAAK,EAEV,SAAS,EACT,WAAW,EACZ,MAAM,0BAA0B,CAAC;AAElC,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,eAAO,MAAM,wBAAwB,wCAAwC,CAAC;AAC9E,eAAO,MAAM,2BAA2B,2CACE,CAAC;AAC3C;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,gCACf,CAAC;AAChC,eAAO,MAAM,sBAAsB,oBAAoB,CAAC;AAKxD,MAAM,WAAW,uBAAuB;IACtC;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EACd,aAAa,CAAC,kBAAkB,CAAC,GACjC,CAAC,MAAM,aAAa,CAAC,kBAAkB,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC;IAC3F,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kEAAkE;IAClE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,kFAAkF;IAClF,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,gCAAgC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,yCAAyC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yCAAyC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;CACrC;AAED,wDAAwD;AACxD,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,uBAAuB,GAC5B,IAAI,CAwWN"}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual, } from 'node:crypto';
|
|
2
|
+
import { formatUserSessionCookieHeader } from './user-session-auth.js';
|
|
3
|
+
import { composeOAuthUserId, } from './oauth-login-types.js';
|
|
4
|
+
export const DEFAULT_OAUTH_START_PATH = '/ggui/oauth-login/:providerId/start';
|
|
5
|
+
export const DEFAULT_OAUTH_CALLBACK_PATH = '/ggui/oauth-login/:providerId/callback';
|
|
6
|
+
/**
|
|
7
|
+
* Public route exposing the currently-configured provider list as
|
|
8
|
+
* `[{providerId, displayName}]`. Anonymous-readable on purpose — the
|
|
9
|
+
* `/login` page fetches this to render only buttons for providers
|
|
10
|
+
* the operator has actually wired up. Returns `[]` when no provider
|
|
11
|
+
* is enabled or credentialed; never leaks `clientSecret`.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_OAUTH_PROVIDERS_LIST_PATH = '/ggui/oauth-login/providers';
|
|
14
|
+
export const OAUTH_PKCE_COOKIE_NAME = 'ggui_oauth_pkce';
|
|
15
|
+
const STATE_TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
const PKCE_COOKIE_TTL_SEC = 600;
|
|
17
|
+
const DEFAULT_NEXT_PATH = '/settings';
|
|
18
|
+
/** Mount the OAuth login routes onto an Express app. */
|
|
19
|
+
export function mountOAuthLoginRoutes(app, opts) {
|
|
20
|
+
const startPath = opts.startPath ?? DEFAULT_OAUTH_START_PATH;
|
|
21
|
+
const callbackPath = opts.callbackPath ?? DEFAULT_OAUTH_CALLBACK_PATH;
|
|
22
|
+
const providersListPath = opts.providersListPath ?? DEFAULT_OAUTH_PROVIDERS_LIST_PATH;
|
|
23
|
+
const auditSink = opts.auditSink;
|
|
24
|
+
// Resolve providers per request when a getter was passed; snapshot
|
|
25
|
+
// once when a static array was passed. Both paths produce a Map
|
|
26
|
+
// keyed on providerId.
|
|
27
|
+
const resolveProvidersById = async () => {
|
|
28
|
+
const list = typeof opts.providers === 'function' ? await opts.providers() : opts.providers;
|
|
29
|
+
const map = new Map();
|
|
30
|
+
for (const p of list)
|
|
31
|
+
map.set(p.providerId, p);
|
|
32
|
+
return map;
|
|
33
|
+
};
|
|
34
|
+
const emitAudit = async (entry, auditLogger) => {
|
|
35
|
+
if (!auditSink)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
await auditSink.record({ at: Date.now(), ...entry });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
auditLogger.warn('audit_emit_failed', {
|
|
42
|
+
action: entry.action,
|
|
43
|
+
error: String(err),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const composeRedirectUri = (providerId) => `${trimTrailingSlash(opts.publicBaseUrl)}/ggui/oauth-login/${providerId}/callback`;
|
|
48
|
+
// --- GET /ggui/oauth-login/providers ---
|
|
49
|
+
// Public-readable list of currently configured + enabled providers.
|
|
50
|
+
// Returns ONLY `{providerId, displayName}` per row — never
|
|
51
|
+
// `clientId` / `clientSecret` (those stay server-side). The /login
|
|
52
|
+
// page fetches this so it can render only buttons backed by a
|
|
53
|
+
// real provider; an unconfigured slot returns 404 from /start
|
|
54
|
+
// anyway, but rendering a dead button is bad UX.
|
|
55
|
+
//
|
|
56
|
+
// No auth required — provider IDs ARE public anyway (they're in
|
|
57
|
+
// every authorize URL the user sees during login). Withholding the
|
|
58
|
+
// list buys nothing security-wise; serving it makes the empty
|
|
59
|
+
// state honest.
|
|
60
|
+
app.get(providersListPath, async (_req, res) => {
|
|
61
|
+
const reqLogger = opts.logger.child({
|
|
62
|
+
route: 'GET ' + providersListPath,
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const providersById = await resolveProvidersById();
|
|
66
|
+
const rows = Array.from(providersById.values()).map((p) => ({
|
|
67
|
+
providerId: p.providerId,
|
|
68
|
+
displayName: p.displayName,
|
|
69
|
+
}));
|
|
70
|
+
res.status(200).json({ providers: rows });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
reqLogger.warn('oauth_providers_list_failed', { error: String(err) });
|
|
74
|
+
res.status(500).json({
|
|
75
|
+
error: {
|
|
76
|
+
code: 'providers_list_failed',
|
|
77
|
+
message: 'Failed to read OAuth provider list.',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// --- GET /ggui/oauth-login/:providerId/start ---
|
|
83
|
+
app.get(startPath, async (req, res) => {
|
|
84
|
+
const providerId = req.params['providerId'];
|
|
85
|
+
const reqLogger = opts.logger.child({
|
|
86
|
+
route: 'GET ' + startPath,
|
|
87
|
+
providerId: providerId ?? '<missing>',
|
|
88
|
+
});
|
|
89
|
+
const providersById = await resolveProvidersById();
|
|
90
|
+
if (!providerId || !providersById.has(providerId)) {
|
|
91
|
+
reqLogger.warn('oauth_start_unknown_provider', {});
|
|
92
|
+
res.status(404).json({
|
|
93
|
+
error: {
|
|
94
|
+
code: 'unknown_provider',
|
|
95
|
+
message: 'No OAuth provider registered for this providerId.',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const provider = providersById.get(providerId);
|
|
101
|
+
const nextRaw = typeof req.query['next'] === 'string'
|
|
102
|
+
? req.query['next']
|
|
103
|
+
: undefined;
|
|
104
|
+
const nextPath = sanitizeNextPath(nextRaw) ?? DEFAULT_NEXT_PATH;
|
|
105
|
+
const verifier = base64url(randomBytes(32));
|
|
106
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
107
|
+
const expiresAt = Date.now() + STATE_TTL_MS;
|
|
108
|
+
const state = signState(opts.stateSecret, {
|
|
109
|
+
random: base64url(randomBytes(16)),
|
|
110
|
+
providerId,
|
|
111
|
+
nextPath,
|
|
112
|
+
expiresAt,
|
|
113
|
+
});
|
|
114
|
+
const redirectUri = composeRedirectUri(providerId);
|
|
115
|
+
const authorizeUrl = provider.authorizeUrl({
|
|
116
|
+
state,
|
|
117
|
+
codeChallenge: challenge,
|
|
118
|
+
redirectUri,
|
|
119
|
+
});
|
|
120
|
+
res.setHeader('Set-Cookie', formatPkceCookieHeader({
|
|
121
|
+
verifier,
|
|
122
|
+
secure: opts.secure ?? false,
|
|
123
|
+
}));
|
|
124
|
+
reqLogger.info('oauth_start', { nextPath });
|
|
125
|
+
await emitAudit({
|
|
126
|
+
action: 'auth.oauth.start',
|
|
127
|
+
actor: { kind: 'anonymous' },
|
|
128
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
129
|
+
}, reqLogger);
|
|
130
|
+
res.redirect(302, authorizeUrl);
|
|
131
|
+
});
|
|
132
|
+
// --- GET /ggui/oauth-login/:providerId/callback ---
|
|
133
|
+
app.get(callbackPath, async (req, res) => {
|
|
134
|
+
const providerId = req.params['providerId'];
|
|
135
|
+
const reqLogger = opts.logger.child({
|
|
136
|
+
route: 'GET ' + callbackPath,
|
|
137
|
+
providerId: providerId ?? '<missing>',
|
|
138
|
+
});
|
|
139
|
+
const errorParam = typeof req.query['error'] === 'string'
|
|
140
|
+
? req.query['error']
|
|
141
|
+
: undefined;
|
|
142
|
+
const codeParam = typeof req.query['code'] === 'string'
|
|
143
|
+
? req.query['code']
|
|
144
|
+
: undefined;
|
|
145
|
+
const stateParam = typeof req.query['state'] === 'string'
|
|
146
|
+
? req.query['state']
|
|
147
|
+
: undefined;
|
|
148
|
+
// Provider returned an error before issuing a code (user denied,
|
|
149
|
+
// provider misconfig, etc.). Surface it as 400 + audit failure.
|
|
150
|
+
if (errorParam) {
|
|
151
|
+
reqLogger.warn('oauth_callback_provider_error', { error: errorParam });
|
|
152
|
+
await emitAudit({
|
|
153
|
+
action: 'auth.oauth.failure',
|
|
154
|
+
actor: { kind: 'anonymous' },
|
|
155
|
+
resource: providerId
|
|
156
|
+
? { kind: 'oauth-provider', id: providerId }
|
|
157
|
+
: undefined,
|
|
158
|
+
metadata: { reason: 'provider_error', detail: errorParam },
|
|
159
|
+
}, reqLogger);
|
|
160
|
+
res.status(400).json({
|
|
161
|
+
error: {
|
|
162
|
+
code: 'oauth_provider_error',
|
|
163
|
+
message: 'OAuth provider returned an error.',
|
|
164
|
+
detail: errorParam,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const providersById = await resolveProvidersById();
|
|
170
|
+
if (!providerId || !providersById.has(providerId)) {
|
|
171
|
+
reqLogger.warn('oauth_callback_unknown_provider', {});
|
|
172
|
+
res.status(404).json({
|
|
173
|
+
error: {
|
|
174
|
+
code: 'unknown_provider',
|
|
175
|
+
message: 'No OAuth provider registered for this providerId.',
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const provider = providersById.get(providerId);
|
|
181
|
+
if (!stateParam || !codeParam) {
|
|
182
|
+
reqLogger.warn('oauth_callback_missing_params', {
|
|
183
|
+
hasCode: Boolean(codeParam),
|
|
184
|
+
hasState: Boolean(stateParam),
|
|
185
|
+
});
|
|
186
|
+
await emitAudit({
|
|
187
|
+
action: 'auth.oauth.failure',
|
|
188
|
+
actor: { kind: 'anonymous' },
|
|
189
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
190
|
+
metadata: { reason: 'missing_params' },
|
|
191
|
+
}, reqLogger);
|
|
192
|
+
res.status(400).json({
|
|
193
|
+
error: {
|
|
194
|
+
code: 'oauth_missing_params',
|
|
195
|
+
message: 'Callback missing required `code` and/or `state` query params.',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const stateValidation = verifyState(opts.stateSecret, stateParam);
|
|
201
|
+
if (!stateValidation.ok) {
|
|
202
|
+
reqLogger.warn('oauth_state_mismatch', { reason: stateValidation.reason });
|
|
203
|
+
await emitAudit({
|
|
204
|
+
action: 'auth.oauth.failure',
|
|
205
|
+
actor: { kind: 'anonymous' },
|
|
206
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
207
|
+
metadata: { reason: 'state_mismatch', detail: stateValidation.reason },
|
|
208
|
+
}, reqLogger);
|
|
209
|
+
res.status(403).json({
|
|
210
|
+
error: {
|
|
211
|
+
code: 'oauth_state_mismatch',
|
|
212
|
+
message: 'OAuth state token is invalid, expired, or tampered.',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const payload = stateValidation.payload;
|
|
218
|
+
// Defense in depth: state must be specific to the URL providerId.
|
|
219
|
+
if (payload.providerId !== providerId) {
|
|
220
|
+
reqLogger.warn('oauth_state_provider_mismatch', {
|
|
221
|
+
statePid: payload.providerId,
|
|
222
|
+
});
|
|
223
|
+
await emitAudit({
|
|
224
|
+
action: 'auth.oauth.failure',
|
|
225
|
+
actor: { kind: 'anonymous' },
|
|
226
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
227
|
+
metadata: { reason: 'provider_mismatch' },
|
|
228
|
+
}, reqLogger);
|
|
229
|
+
res.status(403).json({
|
|
230
|
+
error: {
|
|
231
|
+
code: 'oauth_state_mismatch',
|
|
232
|
+
message: 'State token does not match URL provider.',
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const verifier = readPkceCookie(req);
|
|
238
|
+
if (!verifier) {
|
|
239
|
+
reqLogger.warn('oauth_pkce_missing', {});
|
|
240
|
+
await emitAudit({
|
|
241
|
+
action: 'auth.oauth.failure',
|
|
242
|
+
actor: { kind: 'anonymous' },
|
|
243
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
244
|
+
metadata: { reason: 'pkce_missing' },
|
|
245
|
+
}, reqLogger);
|
|
246
|
+
res.status(403).json({
|
|
247
|
+
error: {
|
|
248
|
+
code: 'oauth_pkce_missing',
|
|
249
|
+
message: 'PKCE verifier cookie is missing — start the flow at /ggui/oauth-login/:providerId/start.',
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const redirectUri = composeRedirectUri(providerId);
|
|
255
|
+
let exchange;
|
|
256
|
+
try {
|
|
257
|
+
exchange = await provider.exchangeCode({
|
|
258
|
+
code: codeParam,
|
|
259
|
+
codeVerifier: verifier,
|
|
260
|
+
redirectUri,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
reqLogger.warn('oauth_exchange_failed', { error: String(err) });
|
|
265
|
+
await emitAudit({
|
|
266
|
+
action: 'auth.oauth.failure',
|
|
267
|
+
actor: { kind: 'anonymous' },
|
|
268
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
269
|
+
metadata: { reason: 'exchange_failed' },
|
|
270
|
+
}, reqLogger);
|
|
271
|
+
res.status(400).json({
|
|
272
|
+
error: {
|
|
273
|
+
code: 'oauth_exchange_failed',
|
|
274
|
+
message: 'OAuth provider rejected the code exchange.',
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!opts.auth.registerToken) {
|
|
280
|
+
reqLogger.warn('oauth_register_token_unsupported', {});
|
|
281
|
+
res.status(501).json({
|
|
282
|
+
error: {
|
|
283
|
+
code: 'not_implemented',
|
|
284
|
+
message: 'AuthAdapter has no registerToken — OAuth login requires a token-registering adapter.',
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const userId = composeOAuthUserId({
|
|
290
|
+
providerId,
|
|
291
|
+
providerSubject: exchange.providerSubject,
|
|
292
|
+
});
|
|
293
|
+
const bearer = `ggui_user_${base64url(randomBytes(16))}`;
|
|
294
|
+
const metadata = {
|
|
295
|
+
providerId,
|
|
296
|
+
providerSubject: exchange.providerSubject,
|
|
297
|
+
};
|
|
298
|
+
if (exchange.email)
|
|
299
|
+
metadata['email'] = exchange.email;
|
|
300
|
+
if (exchange.displayName)
|
|
301
|
+
metadata['displayName'] = exchange.displayName;
|
|
302
|
+
opts.auth.registerToken(bearer, {
|
|
303
|
+
identity: { kind: 'user', userId, roles: [] },
|
|
304
|
+
source: 'oauth',
|
|
305
|
+
metadata,
|
|
306
|
+
});
|
|
307
|
+
const sessionCookie = formatUserSessionCookieHeader({
|
|
308
|
+
bearer,
|
|
309
|
+
...(opts.ttlSec !== undefined ? { ttlSec: opts.ttlSec } : {}),
|
|
310
|
+
...(opts.secure !== undefined ? { secure: opts.secure } : {}),
|
|
311
|
+
});
|
|
312
|
+
const clearPkceCookie = formatClearPkceCookieHeader({
|
|
313
|
+
secure: opts.secure ?? false,
|
|
314
|
+
});
|
|
315
|
+
res.setHeader('Set-Cookie', [sessionCookie, clearPkceCookie]);
|
|
316
|
+
reqLogger.info('oauth_callback_success', {
|
|
317
|
+
userId,
|
|
318
|
+
hasEmail: Boolean(exchange.email),
|
|
319
|
+
});
|
|
320
|
+
const auditMetadata = { providerId };
|
|
321
|
+
if (exchange.email)
|
|
322
|
+
auditMetadata['email'] = exchange.email;
|
|
323
|
+
await emitAudit({
|
|
324
|
+
action: 'auth.oauth.success',
|
|
325
|
+
actor: { kind: 'user', id: userId },
|
|
326
|
+
resource: { kind: 'oauth-provider', id: providerId },
|
|
327
|
+
metadata: auditMetadata,
|
|
328
|
+
}, reqLogger);
|
|
329
|
+
res.redirect(302, payload.nextPath);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function signState(secret, payload) {
|
|
333
|
+
const json = JSON.stringify(payload);
|
|
334
|
+
const payloadB64 = base64url(Buffer.from(json, 'utf8'));
|
|
335
|
+
const sig = createHmac('sha256', secret).update(payloadB64).digest();
|
|
336
|
+
return `${payloadB64}.${base64url(sig)}`;
|
|
337
|
+
}
|
|
338
|
+
function verifyState(secret, token) {
|
|
339
|
+
const parts = token.split('.');
|
|
340
|
+
if (parts.length !== 2)
|
|
341
|
+
return { ok: false, reason: 'malformed' };
|
|
342
|
+
const [payloadB64, sigB64] = parts;
|
|
343
|
+
if (!payloadB64 || !sigB64)
|
|
344
|
+
return { ok: false, reason: 'malformed' };
|
|
345
|
+
const expectedSig = base64url(createHmac('sha256', secret).update(payloadB64).digest());
|
|
346
|
+
if (expectedSig.length !== sigB64.length) {
|
|
347
|
+
return { ok: false, reason: 'sig_length' };
|
|
348
|
+
}
|
|
349
|
+
const macMatch = timingSafeEqual(Buffer.from(expectedSig, 'utf8'), Buffer.from(sigB64, 'utf8'));
|
|
350
|
+
if (!macMatch)
|
|
351
|
+
return { ok: false, reason: 'sig_mismatch' };
|
|
352
|
+
let parsed;
|
|
353
|
+
try {
|
|
354
|
+
const json = base64urlDecode(payloadB64).toString('utf8');
|
|
355
|
+
parsed = JSON.parse(json);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return { ok: false, reason: 'payload_decode' };
|
|
359
|
+
}
|
|
360
|
+
if (!isStatePayload(parsed))
|
|
361
|
+
return { ok: false, reason: 'payload_shape' };
|
|
362
|
+
if (parsed.expiresAt < Date.now())
|
|
363
|
+
return { ok: false, reason: 'expired' };
|
|
364
|
+
return { ok: true, payload: parsed };
|
|
365
|
+
}
|
|
366
|
+
function isStatePayload(value) {
|
|
367
|
+
if (typeof value !== 'object' || value === null)
|
|
368
|
+
return false;
|
|
369
|
+
const v = value;
|
|
370
|
+
return (typeof v.random === 'string' &&
|
|
371
|
+
typeof v.providerId === 'string' &&
|
|
372
|
+
typeof v.nextPath === 'string' &&
|
|
373
|
+
typeof v.expiresAt === 'number');
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Validate `next` query param: must be a relative path (`/foo`),
|
|
377
|
+
* not a protocol-relative URL (`//evil.com`), backslash-prefixed
|
|
378
|
+
* (`\evil.com`), or absolute URL. Returns `null` when invalid so the
|
|
379
|
+
* caller falls back to the default. Open-redirect defense.
|
|
380
|
+
*/
|
|
381
|
+
function sanitizeNextPath(raw) {
|
|
382
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
383
|
+
return null;
|
|
384
|
+
if (!raw.startsWith('/'))
|
|
385
|
+
return null;
|
|
386
|
+
if (raw.startsWith('//'))
|
|
387
|
+
return null;
|
|
388
|
+
if (raw.startsWith('/\\'))
|
|
389
|
+
return null;
|
|
390
|
+
// Backslashes anywhere — IE and some legacy parsers treat `\` as `/`.
|
|
391
|
+
if (raw.includes('\\'))
|
|
392
|
+
return null;
|
|
393
|
+
return raw;
|
|
394
|
+
}
|
|
395
|
+
function formatPkceCookieHeader(input) {
|
|
396
|
+
const attrs = [
|
|
397
|
+
`${OAUTH_PKCE_COOKIE_NAME}=${encodeURIComponent(input.verifier)}`,
|
|
398
|
+
'Path=/',
|
|
399
|
+
`Max-Age=${PKCE_COOKIE_TTL_SEC}`,
|
|
400
|
+
'SameSite=Lax',
|
|
401
|
+
'HttpOnly',
|
|
402
|
+
];
|
|
403
|
+
if (input.secure)
|
|
404
|
+
attrs.push('Secure');
|
|
405
|
+
return attrs.join('; ');
|
|
406
|
+
}
|
|
407
|
+
function formatClearPkceCookieHeader(input) {
|
|
408
|
+
const attrs = [
|
|
409
|
+
`${OAUTH_PKCE_COOKIE_NAME}=`,
|
|
410
|
+
'Path=/',
|
|
411
|
+
'Max-Age=0',
|
|
412
|
+
'SameSite=Lax',
|
|
413
|
+
'HttpOnly',
|
|
414
|
+
];
|
|
415
|
+
if (input.secure)
|
|
416
|
+
attrs.push('Secure');
|
|
417
|
+
return attrs.join('; ');
|
|
418
|
+
}
|
|
419
|
+
function readPkceCookie(req) {
|
|
420
|
+
const raw = req.headers['cookie'];
|
|
421
|
+
if (typeof raw !== 'string')
|
|
422
|
+
return null;
|
|
423
|
+
for (const piece of raw.split(';')) {
|
|
424
|
+
const trimmed = piece.trim();
|
|
425
|
+
const eq = trimmed.indexOf('=');
|
|
426
|
+
if (eq <= 0)
|
|
427
|
+
continue;
|
|
428
|
+
if (trimmed.slice(0, eq) !== OAUTH_PKCE_COOKIE_NAME)
|
|
429
|
+
continue;
|
|
430
|
+
const value = trimmed.slice(eq + 1);
|
|
431
|
+
if (!value)
|
|
432
|
+
return null;
|
|
433
|
+
try {
|
|
434
|
+
return decodeURIComponent(value);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
function trimTrailingSlash(s) {
|
|
443
|
+
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
444
|
+
}
|
|
445
|
+
function base64url(bytes) {
|
|
446
|
+
return bytes
|
|
447
|
+
.toString('base64')
|
|
448
|
+
.replace(/=+$/g, '')
|
|
449
|
+
.replace(/\+/g, '-')
|
|
450
|
+
.replace(/\//g, '_');
|
|
451
|
+
}
|
|
452
|
+
function base64urlDecode(input) {
|
|
453
|
+
const padded = input + '='.repeat((4 - (input.length % 4)) % 4);
|
|
454
|
+
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
455
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth login provider — `OAuthLoginProvider` impl for
|
|
3
|
+
* `github.com` + `api.github.com`. Numeric `id` stringified for
|
|
4
|
+
* `providerSubject`; falls back to `/user/emails` if `/user` returns
|
|
5
|
+
* a null email and `user:email` is in scope.
|
|
6
|
+
*/
|
|
7
|
+
import type { OAuthLoginProvider } from '../oauth-login-types.js';
|
|
8
|
+
export interface GithubLoginProviderOptions {
|
|
9
|
+
readonly clientId: string;
|
|
10
|
+
readonly clientSecret: string;
|
|
11
|
+
/** Override scope list. Default: ['read:user', 'user:email']. */
|
|
12
|
+
readonly scopes?: ReadonlyArray<string>;
|
|
13
|
+
/** Test seam: override fetch (default: globalThis.fetch). Mock in tests. */
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
export declare function githubLoginProvider(opts: GithubLoginProviderOptions): OAuthLoginProvider;
|
|
17
|
+
//# sourceMappingURL=github.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github.d.ts","sourceRoot":"","sources":["../../src/oauth-providers/github.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAIV,kBAAkB,EACnB,MAAM,yBAAyB,CAAC;AASjC,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B;AAmBD,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,0BAA0B,GAC/B,kBAAkB,CA4EpB"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
2
|
+
const AUTHORIZE_ENDPOINT = 'https://github.com/login/oauth/authorize';
|
|
3
|
+
const TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token';
|
|
4
|
+
const USER_ENDPOINT = 'https://api.github.com/user';
|
|
5
|
+
const USER_EMAILS_ENDPOINT = 'https://api.github.com/user/emails';
|
|
6
|
+
const USER_AGENT = 'ggui-oss-server';
|
|
7
|
+
export function githubLoginProvider(opts) {
|
|
8
|
+
const scopes = opts.scopes ?? DEFAULT_SCOPES;
|
|
9
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
10
|
+
return {
|
|
11
|
+
providerId: 'github',
|
|
12
|
+
displayName: 'GitHub',
|
|
13
|
+
authorizeUrl(input) {
|
|
14
|
+
const params = new URLSearchParams({
|
|
15
|
+
response_type: 'code',
|
|
16
|
+
client_id: opts.clientId,
|
|
17
|
+
redirect_uri: input.redirectUri,
|
|
18
|
+
scope: scopes.join(' '),
|
|
19
|
+
state: input.state,
|
|
20
|
+
code_challenge: input.codeChallenge,
|
|
21
|
+
code_challenge_method: 'S256',
|
|
22
|
+
});
|
|
23
|
+
return `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
|
24
|
+
},
|
|
25
|
+
async exchangeCode(input) {
|
|
26
|
+
const tokenBody = new URLSearchParams({
|
|
27
|
+
client_id: opts.clientId,
|
|
28
|
+
client_secret: opts.clientSecret,
|
|
29
|
+
code: input.code,
|
|
30
|
+
redirect_uri: input.redirectUri,
|
|
31
|
+
code_verifier: input.codeVerifier,
|
|
32
|
+
});
|
|
33
|
+
const tokenRes = await fetchImpl(TOKEN_ENDPOINT, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
Accept: 'application/json',
|
|
37
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
38
|
+
},
|
|
39
|
+
body: tokenBody.toString(),
|
|
40
|
+
});
|
|
41
|
+
if (!tokenRes.ok) {
|
|
42
|
+
const text = await tokenRes.text();
|
|
43
|
+
throw new Error(`github_token_exchange_failed: ${tokenRes.status} ${text.slice(0, 200)}`);
|
|
44
|
+
}
|
|
45
|
+
const tokenJson = (await tokenRes.json());
|
|
46
|
+
const accessToken = tokenJson.access_token;
|
|
47
|
+
if (!accessToken) {
|
|
48
|
+
throw new Error('github_token_exchange_missing_access_token');
|
|
49
|
+
}
|
|
50
|
+
const userRes = await fetchImpl(USER_ENDPOINT, {
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${accessToken}`,
|
|
53
|
+
Accept: 'application/vnd.github+json',
|
|
54
|
+
'User-Agent': USER_AGENT,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (!userRes.ok) {
|
|
58
|
+
const text = await userRes.text();
|
|
59
|
+
throw new Error(`github_user_fetch_failed: ${userRes.status} ${text.slice(0, 200)}`);
|
|
60
|
+
}
|
|
61
|
+
const user = (await userRes.json());
|
|
62
|
+
let email = user.email ?? undefined;
|
|
63
|
+
if (!email && scopes.includes('user:email')) {
|
|
64
|
+
email = await fetchPrimaryVerifiedEmail(fetchImpl, accessToken);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
providerSubject: String(user.id),
|
|
68
|
+
email,
|
|
69
|
+
displayName: user.name ?? user.login,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function fetchPrimaryVerifiedEmail(fetchImpl, accessToken) {
|
|
75
|
+
const res = await fetchImpl(USER_EMAILS_ENDPOINT, {
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${accessToken}`,
|
|
78
|
+
Accept: 'application/vnd.github+json',
|
|
79
|
+
'User-Agent': USER_AGENT,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
throw new Error(`github_user_emails_fetch_failed: ${res.status} ${text.slice(0, 200)}`);
|
|
85
|
+
}
|
|
86
|
+
const entries = (await res.json());
|
|
87
|
+
const primary = entries.find((e) => e.primary && e.verified);
|
|
88
|
+
return primary?.email;
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth login provider — `OAuthLoginProvider` impl for
|
|
3
|
+
* `accounts.google.com` + `oauth2.googleapis.com`. Subject from the
|
|
4
|
+
* `id_token` JWT payload (TLS-trusted, signature validation skipped
|
|
5
|
+
* per Google's docs since the token came over TLS direct from the
|
|
6
|
+
* token endpoint).
|
|
7
|
+
*/
|
|
8
|
+
import type { OAuthLoginProvider } from '../oauth-login-types.js';
|
|
9
|
+
export interface GoogleLoginProviderOptions {
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
readonly clientSecret: string;
|
|
12
|
+
/** Override scope list. Default: ['openid', 'email', 'profile']. */
|
|
13
|
+
readonly scopes?: ReadonlyArray<string>;
|
|
14
|
+
/** Test seam: override fetch (default: globalThis.fetch). Mock in tests. */
|
|
15
|
+
readonly fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare function googleLoginProvider(opts: GoogleLoginProviderOptions): OAuthLoginProvider;
|
|
18
|
+
//# sourceMappingURL=google.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../src/oauth-providers/google.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAIV,kBAAkB,EACnB,MAAM,yBAAyB,CAAC;AAMjC,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CAC/B;AAaD,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,0BAA0B,GAC/B,kBAAkB,CAoDpB"}
|