@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.
Files changed (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. 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"}