alepha 0.20.7 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -19
- package/assets/swagger-ui/swagger-ui-bundle.js +1 -1
- package/dist/api/audits/index.d.ts +2 -2
- package/dist/api/audits/index.d.ts.map +1 -1
- package/dist/api/files/index.browser.js +25 -0
- package/dist/api/files/index.browser.js.map +1 -1
- package/dist/api/files/index.d.ts +72 -2
- package/dist/api/files/index.d.ts.map +1 -1
- package/dist/api/files/index.js +82 -7
- package/dist/api/files/index.js.map +1 -1
- package/dist/api/jobs/index.d.ts +7 -5
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +4 -2
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +3 -0
- package/dist/api/keys/index.d.ts.map +1 -1
- package/dist/api/keys/index.js +2 -0
- package/dist/api/keys/index.js.map +1 -1
- package/dist/api/oauth/index.d.ts +287 -0
- package/dist/api/oauth/index.d.ts.map +1 -0
- package/dist/api/oauth/index.js +578 -0
- package/dist/api/oauth/index.js.map +1 -0
- package/dist/api/organizations/index.d.ts.map +1 -1
- package/dist/api/parameters/index.d.ts +2 -2
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +2 -2
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/api/payments/index.d.ts +2 -0
- package/dist/api/payments/index.d.ts.map +1 -1
- package/dist/api/payments/index.js +5 -4
- package/dist/api/payments/index.js.map +1 -1
- package/dist/api/subscriptions/index.d.ts.map +1 -1
- package/dist/api/users/index.browser.js +8 -0
- package/dist/api/users/index.browser.js.map +1 -1
- package/dist/api/users/index.d.ts +286 -5039
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +59 -8
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +2 -0
- package/dist/api/verifications/index.d.ts.map +1 -1
- package/dist/api/verifications/index.js +4 -2
- package/dist/api/verifications/index.js.map +1 -1
- package/dist/batch/index.d.ts +2 -0
- package/dist/batch/index.d.ts.map +1 -1
- package/dist/batch/index.js +3 -1
- package/dist/batch/index.js.map +1 -1
- package/dist/bucket/index.d.ts +5 -0
- package/dist/bucket/index.d.ts.map +1 -1
- package/dist/bucket/index.js +9 -5
- package/dist/bucket/index.js.map +1 -1
- package/dist/bucket/index.workerd.js +5 -3
- package/dist/bucket/index.workerd.js.map +1 -1
- package/dist/cli/core/index.d.ts +157 -18
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +309 -44
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/devtools/index.d.ts.map +1 -1
- package/dist/cli/devtools/index.js +10 -6
- package/dist/cli/devtools/index.js.map +1 -1
- package/dist/cli/i18n/index.d.ts +136 -0
- package/dist/cli/i18n/index.d.ts.map +1 -0
- package/dist/cli/i18n/index.js +245 -0
- package/dist/cli/i18n/index.js.map +1 -0
- package/dist/cli/platform/index.d.ts +39 -1
- package/dist/cli/platform/index.d.ts.map +1 -1
- package/dist/cli/platform/index.js +123 -5
- package/dist/cli/platform/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +34 -9
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +55 -28
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/core/index.d.ts +0 -5
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +0 -22
- package/dist/core/index.js.map +1 -1
- package/dist/crypto/index.browser.js +73 -0
- package/dist/crypto/index.browser.js.map +1 -1
- package/dist/crypto/index.d.ts +10 -0
- package/dist/crypto/index.d.ts.map +1 -1
- package/dist/crypto/index.js +60 -0
- package/dist/crypto/index.js.map +1 -1
- package/dist/mcp/index.d.ts +40 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +33 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/orm/core/index.bun.js +1254 -1070
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +1494 -6228
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +2174 -2011
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js +13 -20
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/react/form/index.d.ts +0 -28
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +18 -4
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/head/index.browser.js +3 -0
- package/dist/react/head/index.browser.js.map +1 -1
- package/dist/react/head/index.d.ts +13 -0
- package/dist/react/head/index.d.ts.map +1 -1
- package/dist/react/head/index.js +3 -0
- package/dist/react/head/index.js.map +1 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +3 -0
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/testing/index.js +3 -3
- package/dist/react/testing/index.js.map +1 -1
- package/dist/redis/index.bun.js +2 -6
- package/dist/redis/index.bun.js.map +1 -1
- package/dist/security/index.d.ts +14 -1
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +11 -7
- package/dist/security/index.js.map +1 -1
- package/dist/server/core/index.d.ts +2 -1
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +3 -3
- package/dist/server/core/index.js.map +1 -1
- package/dist/server/links/index.browser.js +18 -2
- package/dist/server/links/index.browser.js.map +1 -1
- package/dist/server/links/index.d.ts +1 -0
- package/dist/server/links/index.d.ts.map +1 -1
- package/dist/server/links/index.js +18 -2
- package/dist/server/links/index.js.map +1 -1
- package/dist/system/index.d.ts +159 -128
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +249 -181
- package/dist/system/index.js.map +1 -1
- package/package.json +36 -15
- package/src/api/files/__tests__/FileAccessProvider.spec.ts +87 -0
- package/src/api/files/__tests__/FileController.spec.ts +4 -0
- package/src/api/files/controllers/FileController.ts +14 -4
- package/src/api/files/entities/files.ts +25 -0
- package/src/api/files/index.ts +9 -1
- package/src/api/files/providers/FileAccessProvider.ts +52 -0
- package/src/api/files/services/FileService.ts +2 -0
- package/src/api/jobs/providers/JobProvider.ts +4 -2
- package/src/api/keys/controllers/ApiKeyController.ts +1 -0
- package/src/api/keys/schemas/listApiKeyResponseSchema.ts +1 -0
- package/src/api/oauth/__tests__/OAuthClientService.spec.ts +123 -0
- package/src/api/oauth/__tests__/OAuthController.spec.ts +233 -0
- package/src/api/oauth/controllers/OAuthController.ts +275 -0
- package/src/api/oauth/entities/oauthClientEntity.ts +31 -0
- package/src/api/oauth/helpers/consentPage.ts +65 -0
- package/src/api/oauth/helpers/oauthMetadata.ts +29 -0
- package/src/api/oauth/index.ts +38 -0
- package/src/api/oauth/schemas/authorizeDecisionBodySchema.ts +18 -0
- package/src/api/oauth/schemas/authorizeQuerySchema.ts +15 -0
- package/src/api/oauth/schemas/registerClientBodySchema.ts +16 -0
- package/src/api/oauth/schemas/tokenRequestBodySchema.ts +16 -0
- package/src/api/oauth/services/OAuthClientService.ts +267 -0
- package/src/api/parameters/services/ParameterProvider.ts +2 -2
- package/src/api/payments/providers/MemoryPaymentProvider.ts +6 -4
- package/src/api/users/__tests__/ApiKeys.spec.ts +30 -0
- package/src/api/users/__tests__/realmOauth.spec.ts +52 -0
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +7 -0
- package/src/api/users/entities/sessions.ts +8 -0
- package/src/api/users/primitives/$realm.ts +83 -2
- package/src/api/users/services/CredentialService.ts +1 -2
- package/src/api/users/services/RegistrationService.ts +8 -7
- package/src/api/users/services/SessionService.ts +2 -0
- package/src/api/verifications/services/VerificationService.ts +4 -2
- package/src/batch/providers/BatchProvider.ts +3 -1
- package/src/bucket/providers/CloudflareR2Provider.ts +3 -1
- package/src/bucket/providers/LocalFileStorageProvider.ts +3 -2
- package/src/bucket/providers/MemoryFileStorageProvider.ts +3 -2
- package/src/bucket/providers/NodeS3BucketProvider.ts +3 -1
- package/src/cli/core/__tests__/BuildDockerTask.spec.ts +306 -0
- package/src/cli/core/__tests__/init.spec.ts +28 -9
- package/src/cli/core/atoms/buildOptions.ts +45 -0
- package/src/cli/core/commands/build.ts +27 -0
- package/src/cli/core/commands/db.ts +4 -1
- package/src/cli/core/commands/init.ts +0 -3
- package/src/cli/core/commands/lint.ts +4 -8
- package/src/cli/core/commands/test.ts +21 -10
- package/src/cli/core/commands/typecheck.ts +4 -10
- package/src/cli/core/commands/verify.ts +4 -3
- package/src/cli/core/services/AlephaCliUtils.ts +57 -1
- package/src/cli/core/services/PackageManagerUtils.ts +8 -24
- package/src/cli/core/services/ProjectScaffolder.ts +41 -5
- package/src/cli/core/tasks/BuildDockerTask.ts +191 -14
- package/src/cli/core/templates/agentMd.ts +7 -0
- package/src/cli/core/templates/alephaConfigTs.ts +14 -0
- package/src/cli/core/templates/dummySpecTs.ts +15 -3
- package/src/cli/core/templates/vscodeSettingsJson.ts +21 -0
- package/src/cli/devtools/index.ts +10 -6
- package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +128 -0
- package/src/cli/i18n/atoms/i18nOptions.ts +51 -0
- package/src/cli/i18n/commands/I18nCommand.ts +81 -0
- package/src/cli/i18n/index.ts +52 -0
- package/src/cli/i18n/services/I18nCheckService.ts +144 -0
- package/src/cli/platform/adapters/CloudflareAdapter.ts +123 -31
- package/src/cli/platform/commands/SecretsCommand.ts +8 -4
- package/src/cli/platform/schemas/cloudflare.ts +24 -0
- package/src/cli/platform/services/CloudflareApi.ts +44 -0
- package/src/cli/vendor/__tests__/VendorService.spec.ts +32 -14
- package/src/cli/vendor/atoms/vendorOptions.ts +11 -1
- package/src/cli/vendor/commands/VendorCommand.ts +15 -3
- package/src/cli/vendor/services/VendorService.ts +55 -19
- package/src/core/index.ts +0 -32
- package/src/core/interfaces/Run.ts +0 -5
- package/src/crypto/__tests__/BrowserCryptoProvider.browser.spec.ts +58 -0
- package/src/crypto/providers/BrowserCryptoProvider.ts +115 -0
- package/src/crypto/providers/CryptoProvider.ts +99 -0
- package/src/mcp/__tests__/StreamableHttpMcpTransport.spec.ts +117 -0
- package/src/mcp/transports/StreamableHttpMcpTransport.ts +48 -0
- package/src/orm/__tests__/$sequence.spec.ts +87 -2
- package/src/orm/core/entities/alephaSequences.ts +42 -0
- package/src/orm/core/index.bun.ts +21 -20
- package/src/orm/core/index.shared-server.ts +2 -0
- package/src/orm/core/index.ts +2 -0
- package/src/orm/core/primitives/$sequence.ts +68 -27
- package/src/orm/core/providers/SequenceProvider.ts +103 -0
- package/src/orm/core/providers/drivers/CloudflareD1Provider.ts +7 -2
- package/src/orm/core/services/Repository.ts +22 -1
- package/src/orm/postgres/index.bun.ts +5 -12
- package/src/react/form/__tests__/useForm.browser.spec.tsx +434 -0
- package/src/react/form/hooks/useForm.ts +32 -5
- package/src/react/head/interfaces/Head.ts +13 -0
- package/src/react/head/providers/BrowserHeadProvider.ts +9 -0
- package/src/react/router/providers/ReactServerTemplateProvider.ts +4 -0
- package/src/redis/index.bun.ts +2 -6
- package/src/security/primitives/$issuer.ts +14 -0
- package/src/security/providers/ServerSecurityProvider.ts +20 -19
- package/src/server/core/__tests__/BunHttpServerProvider.bun.spec.ts +6 -6
- package/src/server/core/providers/ServerRouterProvider.ts +5 -2
- package/src/server/links/services/BatchCollector.ts +36 -2
- package/src/system/__tests__/BunShellProvider.bun.spec.ts +74 -0
- package/src/system/index.ts +13 -4
- package/src/system/providers/BunShellProvider.ts +81 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { $atom, $inject, $module, $state, Alepha, AlephaError, t } from "alepha";
|
|
2
|
+
import { $logger } from "alepha/logger";
|
|
3
|
+
import { $route } from "alepha/server";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { DateTimeProvider } from "alepha/datetime";
|
|
6
|
+
import { $entity, $repository, db } from "alepha/orm";
|
|
7
|
+
import { JwtProvider } from "alepha/security";
|
|
8
|
+
//#region ../../src/api/oauth/helpers/consentPage.ts
|
|
9
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({
|
|
10
|
+
"&": "&",
|
|
11
|
+
"<": "<",
|
|
12
|
+
">": ">",
|
|
13
|
+
"\"": """,
|
|
14
|
+
"'": "'"
|
|
15
|
+
})[c]);
|
|
16
|
+
const renderConsentPage = (options) => {
|
|
17
|
+
const hidden = Object.entries(options.hidden).map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" />`).join("");
|
|
18
|
+
const scopes = options.scopes.length ? options.scopes.map((s) => `<li>${escapeHtml(s)}</li>`).join("") : "<li>Basic access</li>";
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html lang="en"><head><meta charset="utf-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
22
|
+
<title>Authorize ${escapeHtml(options.clientName)}</title>
|
|
23
|
+
<style>
|
|
24
|
+
body{font-family:system-ui,sans-serif;background:#0b0b0f;color:#e5e5e5;
|
|
25
|
+
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
26
|
+
.card{background:#16161d;border:1px solid #2a2a35;border-radius:12px;
|
|
27
|
+
padding:32px;max-width:380px;width:100%}
|
|
28
|
+
h1{font-size:18px;margin:0 0 4px}p{color:#9a9aa5;font-size:14px;margin:4px 0 16px}
|
|
29
|
+
ul{font-size:14px;padding-left:18px}
|
|
30
|
+
.row{display:flex;gap:8px;margin-top:20px}
|
|
31
|
+
button{flex:1;padding:10px;border-radius:8px;border:0;font-size:14px;cursor:pointer}
|
|
32
|
+
.allow{background:#6d5cf0;color:#fff}.deny{background:#2a2a35;color:#e5e5e5}
|
|
33
|
+
</style></head><body>
|
|
34
|
+
<div class="card">
|
|
35
|
+
<h1>${escapeHtml(options.clientName)} wants to connect</h1>
|
|
36
|
+
<p>Signed in as ${escapeHtml(options.userName)}</p>
|
|
37
|
+
<p>It will be able to:</p>
|
|
38
|
+
<ul>${scopes}</ul>
|
|
39
|
+
<form method="POST" action="/oauth/authorize">${hidden}
|
|
40
|
+
<div class="row">
|
|
41
|
+
<button class="deny" type="submit" name="decision" value="deny">Deny</button>
|
|
42
|
+
<button class="allow" type="submit" name="decision" value="allow">Allow</button>
|
|
43
|
+
</div></form></div></body></html>`;
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region ../../src/api/oauth/helpers/oauthMetadata.ts
|
|
47
|
+
/**
|
|
48
|
+
* RFC 8414 — OAuth 2.0 Authorization Server Metadata.
|
|
49
|
+
* `baseUrl` is the absolute origin of the deployment (e.g. https://app.com).
|
|
50
|
+
*/
|
|
51
|
+
const buildAuthorizationServerMetadata = (baseUrl) => ({
|
|
52
|
+
issuer: baseUrl,
|
|
53
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
54
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
55
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
56
|
+
response_types_supported: ["code"],
|
|
57
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
58
|
+
code_challenge_methods_supported: ["S256"],
|
|
59
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
60
|
+
scopes_supported: ["mcp"]
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* RFC 9728 — OAuth 2.0 Protected Resource Metadata.
|
|
64
|
+
* `resource` is the absolute URL of the MCP endpoint being protected.
|
|
65
|
+
*/
|
|
66
|
+
const buildProtectedResourceMetadata = (baseUrl, resource) => ({
|
|
67
|
+
resource,
|
|
68
|
+
authorization_servers: [baseUrl],
|
|
69
|
+
scopes_supported: ["mcp"],
|
|
70
|
+
bearer_methods_supported: ["header"]
|
|
71
|
+
});
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region ../../src/api/oauth/schemas/authorizeDecisionBodySchema.ts
|
|
74
|
+
/**
|
|
75
|
+
* Body posted by the consent screen. All authorization-request parameters
|
|
76
|
+
* are round-tripped through hidden form fields so the POST handler can
|
|
77
|
+
* re-validate them without server-side session state.
|
|
78
|
+
*/
|
|
79
|
+
const authorizeDecisionBodySchema = t.object({
|
|
80
|
+
decision: t.text(),
|
|
81
|
+
response_type: t.text(),
|
|
82
|
+
client_id: t.text(),
|
|
83
|
+
redirect_uri: t.text({ maxLength: 2048 }),
|
|
84
|
+
code_challenge: t.text(),
|
|
85
|
+
code_challenge_method: t.text(),
|
|
86
|
+
scope: t.optional(t.text({ maxLength: 1024 })),
|
|
87
|
+
state: t.optional(t.text({ maxLength: 512 })),
|
|
88
|
+
resource: t.optional(t.text({ maxLength: 2048 }))
|
|
89
|
+
});
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region ../../src/api/oauth/schemas/authorizeQuerySchema.ts
|
|
92
|
+
/**
|
|
93
|
+
* OAuth 2.1 authorization request query parameters (GET /oauth/authorize).
|
|
94
|
+
*/
|
|
95
|
+
const authorizeQuerySchema = t.object({
|
|
96
|
+
response_type: t.text(),
|
|
97
|
+
client_id: t.text(),
|
|
98
|
+
redirect_uri: t.text({ maxLength: 2048 }),
|
|
99
|
+
code_challenge: t.text(),
|
|
100
|
+
code_challenge_method: t.text(),
|
|
101
|
+
scope: t.optional(t.text({ maxLength: 1024 })),
|
|
102
|
+
state: t.optional(t.text({ maxLength: 512 })),
|
|
103
|
+
resource: t.optional(t.text({ maxLength: 2048 }))
|
|
104
|
+
});
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region ../../src/api/oauth/schemas/registerClientBodySchema.ts
|
|
107
|
+
/**
|
|
108
|
+
* RFC 7591 Dynamic Client Registration request body.
|
|
109
|
+
* Only the fields Alepha consumes are typed; unknown fields are ignored.
|
|
110
|
+
*/
|
|
111
|
+
const registerClientBodySchema = t.object({
|
|
112
|
+
client_name: t.optional(t.text({ maxLength: 200 })),
|
|
113
|
+
redirect_uris: t.array(t.text({ maxLength: 2048 }), { minItems: 1 }),
|
|
114
|
+
scope: t.optional(t.text({ maxLength: 1024 })),
|
|
115
|
+
grant_types: t.optional(t.array(t.text())),
|
|
116
|
+
token_endpoint_auth_method: t.optional(t.text())
|
|
117
|
+
}, { additionalProperties: true });
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region ../../src/api/oauth/schemas/tokenRequestBodySchema.ts
|
|
120
|
+
/**
|
|
121
|
+
* Body of a POST /oauth/token request. OAuth 2.1 mandates
|
|
122
|
+
* application/x-www-form-urlencoded encoding (section 3.2.2). All fields are
|
|
123
|
+
* optional at the schema level; the handler enforces the grant-specific
|
|
124
|
+
* requirements and returns the appropriate OAuth error responses.
|
|
125
|
+
*/
|
|
126
|
+
const tokenRequestBodySchema = t.object({
|
|
127
|
+
grant_type: t.optional(t.text()),
|
|
128
|
+
code: t.optional(t.text({ maxLength: 4096 })),
|
|
129
|
+
client_id: t.optional(t.text()),
|
|
130
|
+
redirect_uri: t.optional(t.text({ maxLength: 2048 })),
|
|
131
|
+
code_verifier: t.optional(t.text({ maxLength: 256 })),
|
|
132
|
+
refresh_token: t.optional(t.text({ maxLength: 4096 }))
|
|
133
|
+
});
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region ../../src/api/oauth/entities/oauthClientEntity.ts
|
|
136
|
+
/**
|
|
137
|
+
* A registered OAuth 2.1 client application.
|
|
138
|
+
*
|
|
139
|
+
* Rows are created by Dynamic Client Registration (RFC 7591) when an MCP
|
|
140
|
+
* client (e.g. Claude) first connects. `source` records who created the
|
|
141
|
+
* client; for DCR it is always `"dcr"` and `createdByUserId` is null until
|
|
142
|
+
* a user completes an authorization.
|
|
143
|
+
*/
|
|
144
|
+
const oauthClientEntity = $entity({
|
|
145
|
+
name: "oauth_clients",
|
|
146
|
+
schema: t.object({
|
|
147
|
+
id: db.primaryKey(t.uuid()),
|
|
148
|
+
createdAt: db.createdAt(),
|
|
149
|
+
updatedAt: db.updatedAt(),
|
|
150
|
+
clientId: t.text({ maxLength: 64 }),
|
|
151
|
+
clientName: t.text({ maxLength: 200 }),
|
|
152
|
+
redirectUris: db.default(t.array(t.text({ maxLength: 2048 })), []),
|
|
153
|
+
scopes: db.default(t.array(t.text({ maxLength: 64 })), []),
|
|
154
|
+
realm: t.text({ maxLength: 100 }),
|
|
155
|
+
source: db.default(t.text({ maxLength: 16 }), "dcr"),
|
|
156
|
+
createdByUserId: t.optional(t.uuid()),
|
|
157
|
+
lastUsedAt: t.optional(t.datetime()),
|
|
158
|
+
revokedAt: t.optional(t.datetime())
|
|
159
|
+
}),
|
|
160
|
+
indexes: [{
|
|
161
|
+
columns: ["clientId"],
|
|
162
|
+
unique: true
|
|
163
|
+
}]
|
|
164
|
+
});
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region ../../src/api/oauth/services/OAuthClientService.ts
|
|
167
|
+
/**
|
|
168
|
+
* Core OAuth 2.1 service backing the authorization server.
|
|
169
|
+
*
|
|
170
|
+
* Responsibilities:
|
|
171
|
+
* - Client registration (RFC 7591 Dynamic Client Registration) and lookup,
|
|
172
|
+
* with exact-match redirect_uri validation.
|
|
173
|
+
* - Stateless PKCE authorization codes: minting short-lived signed JWTs that
|
|
174
|
+
* carry the grant, and verifying/consuming them (replay, expiry, client and
|
|
175
|
+
* redirect_uri checks, S256 PKCE).
|
|
176
|
+
* - Realm issuer registry: realms register an issuer + user loader so the
|
|
177
|
+
* token endpoint can mint access tokens without depending on realm wiring.
|
|
178
|
+
*/
|
|
179
|
+
var OAuthClientService = class {
|
|
180
|
+
alepha = $inject(Alepha);
|
|
181
|
+
dateTime = $inject(DateTimeProvider);
|
|
182
|
+
log = $logger();
|
|
183
|
+
repo = $repository(oauthClientEntity);
|
|
184
|
+
jwt = $inject(JwtProvider);
|
|
185
|
+
/**
|
|
186
|
+
* Codes already redeemed in this process. Single-use enforcement only
|
|
187
|
+
* needs to cover the ~60s code lifetime, so a bounded in-memory set is
|
|
188
|
+
* sufficient even on serverless — an expired code fails JWT verification
|
|
189
|
+
* regardless.
|
|
190
|
+
*/
|
|
191
|
+
usedCodes = /* @__PURE__ */ new Set();
|
|
192
|
+
/**
|
|
193
|
+
* Registry of realm issuers used to mint access tokens. Populated by
|
|
194
|
+
* `$realm` (via `registerIssuer`) so the OAuth module does not depend on the
|
|
195
|
+
* realm wiring directly.
|
|
196
|
+
*/
|
|
197
|
+
issuers = /* @__PURE__ */ new Map();
|
|
198
|
+
/**
|
|
199
|
+
* Register a realm issuer and a user loader. Called by `$realm` so the
|
|
200
|
+
* OAuth token endpoint can mint access tokens for that realm.
|
|
201
|
+
*/
|
|
202
|
+
registerIssuer(realm, issuer, loadUser) {
|
|
203
|
+
this.issuers.set(realm, {
|
|
204
|
+
issuer,
|
|
205
|
+
loadUser
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Mint an access token for a consumed authorization-code grant, using the
|
|
210
|
+
* issuer registered for `realm`. Throws if the realm has no issuer.
|
|
211
|
+
*/
|
|
212
|
+
async issueAccessToken(realm, grant) {
|
|
213
|
+
const entry = this.issuers.get(realm);
|
|
214
|
+
if (!entry) throw new AlephaError(`No issuer registered for realm '${realm}'`);
|
|
215
|
+
const user = await entry.loadUser(grant.userId);
|
|
216
|
+
const tokens = await entry.issuer.createToken(user, void 0, { clientId: grant.clientId });
|
|
217
|
+
return {
|
|
218
|
+
access_token: tokens.access_token,
|
|
219
|
+
expires_in: tokens.expires_in,
|
|
220
|
+
refresh_token: tokens.refresh_token
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Exchange a refresh token for a fresh access token (OAuth 2.1
|
|
225
|
+
* `refresh_token` grant), using the issuer registered for `realm`. Lets an
|
|
226
|
+
* MCP client stay connected for the refresh token's full lifetime without
|
|
227
|
+
* re-running the authorization flow. Throws if the realm has no issuer or
|
|
228
|
+
* the refresh token is invalid/expired.
|
|
229
|
+
*/
|
|
230
|
+
async refreshAccessToken(realm, refreshToken) {
|
|
231
|
+
const entry = this.issuers.get(realm);
|
|
232
|
+
if (!entry) throw new AlephaError(`No issuer registered for realm '${realm}'`);
|
|
233
|
+
const { tokens } = await entry.issuer.refreshToken(refreshToken);
|
|
234
|
+
return {
|
|
235
|
+
access_token: tokens.access_token,
|
|
236
|
+
expires_in: tokens.expires_in,
|
|
237
|
+
refresh_token: tokens.refresh_token
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Register a new OAuth client. Used by the RFC 7591 DCR endpoint and,
|
|
242
|
+
* later, by user/admin UIs (via the `source` field).
|
|
243
|
+
*/
|
|
244
|
+
async register(options) {
|
|
245
|
+
if (options.redirectUris.length === 0) throw new AlephaError("At least one redirect_uri is required");
|
|
246
|
+
for (const uri of options.redirectUris) if (!uri.startsWith("https://") && !uri.startsWith("http://localhost")) throw new AlephaError(`Invalid redirect_uri: ${uri}`);
|
|
247
|
+
const clientId = `mcp_${randomUUID().replace(/-/g, "")}`;
|
|
248
|
+
const client = await this.repo.create({
|
|
249
|
+
clientId,
|
|
250
|
+
clientName: options.clientName || "MCP Client",
|
|
251
|
+
redirectUris: options.redirectUris,
|
|
252
|
+
scopes: options.scopes,
|
|
253
|
+
realm: options.realm,
|
|
254
|
+
source: options.source ?? "dcr",
|
|
255
|
+
createdByUserId: options.createdByUserId
|
|
256
|
+
});
|
|
257
|
+
this.log.info("OAuth client registered", {
|
|
258
|
+
clientId,
|
|
259
|
+
source: client.source
|
|
260
|
+
});
|
|
261
|
+
return client;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Look up a client by its public `clientId`. Returns null if unknown.
|
|
265
|
+
*/
|
|
266
|
+
async findByClientId(clientId) {
|
|
267
|
+
return await this.repo.findOne({ where: { clientId: { eq: clientId } } }) ?? null;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Exact-match redirect_uri check. OAuth 2.1 forbids substring/prefix
|
|
271
|
+
* matching — the value must equal a registered URI byte-for-byte.
|
|
272
|
+
*/
|
|
273
|
+
isRedirectUriAllowed(client, redirectUri) {
|
|
274
|
+
return client.redirectUris.includes(redirectUri);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Mint a stateless authorization code: a short-lived signed JWT
|
|
278
|
+
* (`typ: "oauth_code"`) carrying the grant. No server-side code storage.
|
|
279
|
+
*/
|
|
280
|
+
async createAuthorizationCode(realm, grant) {
|
|
281
|
+
const iat = this.dateTime.now().unix();
|
|
282
|
+
return this.jwt.create({
|
|
283
|
+
sub: grant.userId,
|
|
284
|
+
client_id: grant.clientId,
|
|
285
|
+
redirect_uri: grant.redirectUri,
|
|
286
|
+
code_challenge: grant.codeChallenge,
|
|
287
|
+
scopes: grant.scopes,
|
|
288
|
+
resource: grant.resource,
|
|
289
|
+
iat,
|
|
290
|
+
exp: iat + 60,
|
|
291
|
+
jti: randomUUID()
|
|
292
|
+
}, realm, { header: { typ: "oauth_code" } });
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Verify and atomically consume an authorization code. Throws on expiry,
|
|
296
|
+
* replay, client/redirect mismatch, or PKCE failure.
|
|
297
|
+
*/
|
|
298
|
+
async consumeAuthorizationCode(realm, code, check) {
|
|
299
|
+
const { result } = await this.jwt.parse(code, realm, { typ: "oauth_code" });
|
|
300
|
+
const payload = result.payload;
|
|
301
|
+
const jti = payload.jti;
|
|
302
|
+
if (this.usedCodes.has(jti)) throw new AlephaError("Authorization code already used");
|
|
303
|
+
if (payload.client_id !== check.clientId) throw new AlephaError("client_id mismatch");
|
|
304
|
+
if (payload.redirect_uri !== check.redirectUri) throw new AlephaError("redirect_uri mismatch");
|
|
305
|
+
if (createHash("sha256").update(check.codeVerifier).digest("base64url") !== payload.code_challenge) throw new AlephaError("PKCE verification failed");
|
|
306
|
+
this.usedCodes.add(jti);
|
|
307
|
+
return {
|
|
308
|
+
userId: payload.sub,
|
|
309
|
+
scopes: payload.scopes ?? [],
|
|
310
|
+
resource: payload.resource
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region ../../src/api/oauth/controllers/OAuthController.ts
|
|
316
|
+
/**
|
|
317
|
+
* Configuration for the OAuth authorization server.
|
|
318
|
+
* `realm` is the issuer realm whose JWTs are minted as access tokens;
|
|
319
|
+
* `resource` is the path of the protected MCP endpoint;
|
|
320
|
+
* `loginPath` is the app-level login page unauthenticated users are
|
|
321
|
+
* redirected to from the authorize endpoint.
|
|
322
|
+
*/
|
|
323
|
+
const oauthOptions = $atom({
|
|
324
|
+
name: "alepha.api.oauth.options",
|
|
325
|
+
description: "Configuration for the OAuth authorization server.",
|
|
326
|
+
schema: t.object({
|
|
327
|
+
realm: t.text({ default: "users" }),
|
|
328
|
+
resource: t.text({ default: "/mcp" }),
|
|
329
|
+
loginPath: t.text({ default: "/login" })
|
|
330
|
+
}),
|
|
331
|
+
default: {
|
|
332
|
+
realm: "users",
|
|
333
|
+
resource: "/mcp",
|
|
334
|
+
loginPath: "/login"
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
/**
|
|
338
|
+
* OAuth 2.1 authorization server endpoints: discovery metadata and
|
|
339
|
+
* RFC 7591 dynamic client registration. Authorize/token routes are added
|
|
340
|
+
* separately.
|
|
341
|
+
*/
|
|
342
|
+
var OAuthController = class {
|
|
343
|
+
log = $logger();
|
|
344
|
+
options = $state(oauthOptions);
|
|
345
|
+
clients = $inject(OAuthClientService);
|
|
346
|
+
/**
|
|
347
|
+
* Absolute origin of the current request, e.g. https://app.com.
|
|
348
|
+
*/
|
|
349
|
+
baseUrl(url) {
|
|
350
|
+
return `${url.protocol}//${url.host}`;
|
|
351
|
+
}
|
|
352
|
+
metadata = $route({
|
|
353
|
+
method: "GET",
|
|
354
|
+
path: "/.well-known/oauth-authorization-server",
|
|
355
|
+
handler: ({ url, reply }) => {
|
|
356
|
+
reply.headers["content-type"] = "application/json";
|
|
357
|
+
reply.body = JSON.stringify(buildAuthorizationServerMetadata(this.baseUrl(url)));
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
protectedResource = $route({
|
|
361
|
+
method: "GET",
|
|
362
|
+
path: "/.well-known/oauth-protected-resource",
|
|
363
|
+
handler: ({ url, reply }) => {
|
|
364
|
+
const base = this.baseUrl(url);
|
|
365
|
+
reply.headers["content-type"] = "application/json";
|
|
366
|
+
reply.body = JSON.stringify(buildProtectedResourceMetadata(base, `${base}${this.options.resource}`));
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
register = $route({
|
|
370
|
+
method: "POST",
|
|
371
|
+
path: "/oauth/register",
|
|
372
|
+
schema: { body: registerClientBodySchema },
|
|
373
|
+
handler: async ({ body, reply }) => {
|
|
374
|
+
const client = await this.clients.register({
|
|
375
|
+
realm: this.options.realm,
|
|
376
|
+
clientName: body.client_name ?? "MCP Client",
|
|
377
|
+
redirectUris: body.redirect_uris,
|
|
378
|
+
scopes: body.scope ? body.scope.split(" ") : ["mcp"],
|
|
379
|
+
source: "dcr"
|
|
380
|
+
});
|
|
381
|
+
reply.status = 201;
|
|
382
|
+
reply.headers["content-type"] = "application/json";
|
|
383
|
+
reply.body = JSON.stringify({
|
|
384
|
+
client_id: client.clientId,
|
|
385
|
+
client_id_issued_at: Math.floor(new Date(client.createdAt).getTime() / 1e3),
|
|
386
|
+
client_name: client.clientName,
|
|
387
|
+
redirect_uris: client.redirectUris,
|
|
388
|
+
grant_types: ["authorization_code"],
|
|
389
|
+
token_endpoint_auth_method: "none"
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
/**
|
|
394
|
+
* GET /oauth/authorize — OAuth 2.1 authorization request. If the user
|
|
395
|
+
* has no session, redirect to the realm login page with a return URL.
|
|
396
|
+
* If authenticated, render the consent screen.
|
|
397
|
+
*/
|
|
398
|
+
authorize = $route({
|
|
399
|
+
method: "GET",
|
|
400
|
+
path: "/oauth/authorize",
|
|
401
|
+
schema: { query: authorizeQuerySchema },
|
|
402
|
+
use: [],
|
|
403
|
+
handler: async ({ query, user, url, reply }) => {
|
|
404
|
+
if (query.response_type !== "code") {
|
|
405
|
+
reply.status = 400;
|
|
406
|
+
reply.body = "unsupported response_type";
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (query.code_challenge_method !== "S256") {
|
|
410
|
+
reply.status = 400;
|
|
411
|
+
reply.body = "code_challenge_method must be S256";
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const client = await this.clients.findByClientId(query.client_id);
|
|
415
|
+
if (!client || client.revokedAt) {
|
|
416
|
+
reply.status = 400;
|
|
417
|
+
reply.body = "unknown client_id";
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!this.clients.isRedirectUriAllowed(client, query.redirect_uri)) {
|
|
421
|
+
reply.status = 400;
|
|
422
|
+
reply.body = "redirect_uri not registered";
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!user) {
|
|
426
|
+
const returnTo = encodeURIComponent(url.pathname + url.search);
|
|
427
|
+
reply.redirect(`${this.options.loginPath}?redirect_uri=${returnTo}`, 302);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
reply.headers["content-type"] = "text/html; charset=utf-8";
|
|
431
|
+
reply.body = renderConsentPage({
|
|
432
|
+
clientName: client.clientName,
|
|
433
|
+
userName: user.name ?? user.email ?? "your account",
|
|
434
|
+
scopes: query.scope ? query.scope.split(" ") : client.scopes,
|
|
435
|
+
hidden: {
|
|
436
|
+
response_type: query.response_type,
|
|
437
|
+
client_id: query.client_id,
|
|
438
|
+
redirect_uri: query.redirect_uri,
|
|
439
|
+
code_challenge: query.code_challenge,
|
|
440
|
+
code_challenge_method: query.code_challenge_method,
|
|
441
|
+
scope: query.scope ?? "",
|
|
442
|
+
state: query.state ?? "",
|
|
443
|
+
resource: query.resource ?? ""
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
/**
|
|
449
|
+
* POST /oauth/authorize — consent decision. On "allow", mint an
|
|
450
|
+
* authorization code and redirect back to the client's redirect_uri.
|
|
451
|
+
*
|
|
452
|
+
* CSRF: this route carries no CSRF token and relies solely on the session
|
|
453
|
+
* cookie to identify the user. This is a deliberate MVP/MCP tradeoff — a
|
|
454
|
+
* forged consent submit can still only issue an authorization code to an
|
|
455
|
+
* already-registered client, and that code is bound by PKCE, so the
|
|
456
|
+
* attacker cannot redeem it without the matching code_verifier.
|
|
457
|
+
*/
|
|
458
|
+
authorizeDecision = $route({
|
|
459
|
+
method: "POST",
|
|
460
|
+
path: "/oauth/authorize",
|
|
461
|
+
schema: { body: authorizeDecisionBodySchema },
|
|
462
|
+
use: [],
|
|
463
|
+
handler: async ({ body, user, reply }) => {
|
|
464
|
+
if (!user) {
|
|
465
|
+
reply.status = 401;
|
|
466
|
+
reply.body = "authentication required";
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const client = await this.clients.findByClientId(body.client_id);
|
|
470
|
+
if (!client || client.revokedAt || !this.clients.isRedirectUriAllowed(client, body.redirect_uri)) {
|
|
471
|
+
reply.status = 400;
|
|
472
|
+
reply.body = "invalid client";
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const redirect = new URL(body.redirect_uri);
|
|
476
|
+
if (body.decision !== "allow") {
|
|
477
|
+
redirect.searchParams.set("error", "access_denied");
|
|
478
|
+
if (body.state) redirect.searchParams.set("state", body.state);
|
|
479
|
+
reply.redirect(redirect.toString(), 302);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const code = await this.clients.createAuthorizationCode(this.options.realm, {
|
|
483
|
+
userId: user.id,
|
|
484
|
+
clientId: body.client_id,
|
|
485
|
+
redirectUri: body.redirect_uri,
|
|
486
|
+
codeChallenge: body.code_challenge,
|
|
487
|
+
scopes: body.scope ? body.scope.split(" ") : client.scopes,
|
|
488
|
+
resource: body.resource || void 0
|
|
489
|
+
});
|
|
490
|
+
redirect.searchParams.set("code", code);
|
|
491
|
+
if (body.state) redirect.searchParams.set("state", body.state);
|
|
492
|
+
reply.redirect(redirect.toString(), 302);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
/**
|
|
496
|
+
* POST /oauth/token — supports the `authorization_code` grant (verifies
|
|
497
|
+
* PKCE, mints an access token via the realm issuer) and the
|
|
498
|
+
* `refresh_token` grant (exchanges a refresh token for a fresh access
|
|
499
|
+
* token, so a client stays connected without re-running the flow).
|
|
500
|
+
*/
|
|
501
|
+
token = $route({
|
|
502
|
+
method: "POST",
|
|
503
|
+
path: "/oauth/token",
|
|
504
|
+
schema: { body: tokenRequestBodySchema },
|
|
505
|
+
use: [],
|
|
506
|
+
handler: async ({ body, reply }) => {
|
|
507
|
+
reply.headers["content-type"] = "application/json";
|
|
508
|
+
try {
|
|
509
|
+
if (body.grant_type === "authorization_code") {
|
|
510
|
+
const grant = await this.clients.consumeAuthorizationCode(this.options.realm, body.code ?? "", {
|
|
511
|
+
clientId: body.client_id ?? "",
|
|
512
|
+
redirectUri: body.redirect_uri ?? "",
|
|
513
|
+
codeVerifier: body.code_verifier ?? ""
|
|
514
|
+
});
|
|
515
|
+
const tokens = await this.clients.issueAccessToken(this.options.realm, {
|
|
516
|
+
...grant,
|
|
517
|
+
clientId: body.client_id ?? ""
|
|
518
|
+
});
|
|
519
|
+
reply.body = JSON.stringify({
|
|
520
|
+
access_token: tokens.access_token,
|
|
521
|
+
token_type: "Bearer",
|
|
522
|
+
expires_in: tokens.expires_in,
|
|
523
|
+
refresh_token: tokens.refresh_token,
|
|
524
|
+
scope: grant.scopes.join(" ")
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (body.grant_type === "refresh_token") {
|
|
529
|
+
const tokens = await this.clients.refreshAccessToken(this.options.realm, body.refresh_token ?? "");
|
|
530
|
+
reply.body = JSON.stringify({
|
|
531
|
+
access_token: tokens.access_token,
|
|
532
|
+
token_type: "Bearer",
|
|
533
|
+
expires_in: tokens.expires_in,
|
|
534
|
+
refresh_token: tokens.refresh_token
|
|
535
|
+
});
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
reply.status = 400;
|
|
539
|
+
reply.body = JSON.stringify({ error: "unsupported_grant_type" });
|
|
540
|
+
} catch (e) {
|
|
541
|
+
this.log.warn("OAuth token exchange failed", e);
|
|
542
|
+
reply.status = 400;
|
|
543
|
+
reply.body = JSON.stringify({ error: "invalid_grant" });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
//#endregion
|
|
549
|
+
//#region ../../src/api/oauth/index.ts
|
|
550
|
+
/**
|
|
551
|
+
* OAuth 2.1 authorization server module for MCP.
|
|
552
|
+
*
|
|
553
|
+
* **Features:**
|
|
554
|
+
* - OAuth 2.1 authorization code flow with PKCE (RFC 7636)
|
|
555
|
+
* - Dynamic Client Registration (RFC 7591)
|
|
556
|
+
* - Authorization server metadata discovery (RFC 8414)
|
|
557
|
+
* - Stateless authorization codes (short-lived signed JWTs)
|
|
558
|
+
* - Single-use code enforcement
|
|
559
|
+
*
|
|
560
|
+
* **Integration:**
|
|
561
|
+
* Register the module and configure the realm + protected resource path:
|
|
562
|
+
*
|
|
563
|
+
* ```ts
|
|
564
|
+
* const app = Alepha.create()
|
|
565
|
+
* .with(AlephaOAuth)
|
|
566
|
+
* .set(oauthOptions, { realm: "users", resource: "/mcp" });
|
|
567
|
+
* ```
|
|
568
|
+
*
|
|
569
|
+
* @module alepha.api.oauth
|
|
570
|
+
*/
|
|
571
|
+
const AlephaOAuth = $module({
|
|
572
|
+
name: "alepha.api.oauth",
|
|
573
|
+
services: [OAuthClientService, OAuthController]
|
|
574
|
+
});
|
|
575
|
+
//#endregion
|
|
576
|
+
export { AlephaOAuth, OAuthClientService, OAuthController, oauthClientEntity, oauthOptions };
|
|
577
|
+
|
|
578
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/api/oauth/helpers/consentPage.ts","../../../src/api/oauth/helpers/oauthMetadata.ts","../../../src/api/oauth/schemas/authorizeDecisionBodySchema.ts","../../../src/api/oauth/schemas/authorizeQuerySchema.ts","../../../src/api/oauth/schemas/registerClientBodySchema.ts","../../../src/api/oauth/schemas/tokenRequestBodySchema.ts","../../../src/api/oauth/entities/oauthClientEntity.ts","../../../src/api/oauth/services/OAuthClientService.ts","../../../src/api/oauth/controllers/OAuthController.ts","../../../src/api/oauth/index.ts"],"sourcesContent":["/**\n * Renders the OAuth consent screen as a self-contained HTML document.\n * Pure server-rendered HTML — no client framework, no @alepha/ui dependency.\n * All authorization parameters are emitted as hidden inputs so the POST to\n * /oauth/authorize is stateless.\n */\nexport interface ConsentPageOptions {\n clientName: string;\n userName: string;\n scopes: string[];\n /**\n * Hidden field name -> value; round-trips the authorization request.\n */\n hidden: Record<string, string>;\n}\n\nconst escapeHtml = (s: string): string =>\n s.replace(\n /[&<>\"']/g,\n (c) =>\n ({\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n })[c] as string,\n );\n\nexport const renderConsentPage = (options: ConsentPageOptions): string => {\n const hidden = Object.entries(options.hidden)\n .map(\n ([k, v]) =>\n `<input type=\"hidden\" name=\"${escapeHtml(k)}\" value=\"${escapeHtml(v)}\" />`,\n )\n .join(\"\");\n const scopes = options.scopes.length\n ? options.scopes.map((s) => `<li>${escapeHtml(s)}</li>`).join(\"\")\n : \"<li>Basic access</li>\";\n return `<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n<title>Authorize ${escapeHtml(options.clientName)}</title>\n<style>\nbody{font-family:system-ui,sans-serif;background:#0b0b0f;color:#e5e5e5;\ndisplay:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}\n.card{background:#16161d;border:1px solid #2a2a35;border-radius:12px;\npadding:32px;max-width:380px;width:100%}\nh1{font-size:18px;margin:0 0 4px}p{color:#9a9aa5;font-size:14px;margin:4px 0 16px}\nul{font-size:14px;padding-left:18px}\n.row{display:flex;gap:8px;margin-top:20px}\nbutton{flex:1;padding:10px;border-radius:8px;border:0;font-size:14px;cursor:pointer}\n.allow{background:#6d5cf0;color:#fff}.deny{background:#2a2a35;color:#e5e5e5}\n</style></head><body>\n<div class=\"card\">\n<h1>${escapeHtml(options.clientName)} wants to connect</h1>\n<p>Signed in as ${escapeHtml(options.userName)}</p>\n<p>It will be able to:</p>\n<ul>${scopes}</ul>\n<form method=\"POST\" action=\"/oauth/authorize\">${hidden}\n<div class=\"row\">\n<button class=\"deny\" type=\"submit\" name=\"decision\" value=\"deny\">Deny</button>\n<button class=\"allow\" type=\"submit\" name=\"decision\" value=\"allow\">Allow</button>\n</div></form></div></body></html>`;\n};\n","/**\n * RFC 8414 — OAuth 2.0 Authorization Server Metadata.\n * `baseUrl` is the absolute origin of the deployment (e.g. https://app.com).\n */\nexport const buildAuthorizationServerMetadata = (baseUrl: string) => ({\n issuer: baseUrl,\n authorization_endpoint: `${baseUrl}/oauth/authorize`,\n token_endpoint: `${baseUrl}/oauth/token`,\n registration_endpoint: `${baseUrl}/oauth/register`,\n response_types_supported: [\"code\"],\n grant_types_supported: [\"authorization_code\", \"refresh_token\"],\n code_challenge_methods_supported: [\"S256\"],\n token_endpoint_auth_methods_supported: [\"none\"],\n scopes_supported: [\"mcp\"],\n});\n\n/**\n * RFC 9728 — OAuth 2.0 Protected Resource Metadata.\n * `resource` is the absolute URL of the MCP endpoint being protected.\n */\nexport const buildProtectedResourceMetadata = (\n baseUrl: string,\n resource: string,\n) => ({\n resource,\n authorization_servers: [baseUrl],\n scopes_supported: [\"mcp\"],\n bearer_methods_supported: [\"header\"],\n});\n","import { t } from \"alepha\";\n\n/**\n * Body posted by the consent screen. All authorization-request parameters\n * are round-tripped through hidden form fields so the POST handler can\n * re-validate them without server-side session state.\n */\nexport const authorizeDecisionBodySchema = t.object({\n decision: t.text(),\n response_type: t.text(),\n client_id: t.text(),\n redirect_uri: t.text({ maxLength: 2048 }),\n code_challenge: t.text(),\n code_challenge_method: t.text(),\n scope: t.optional(t.text({ maxLength: 1024 })),\n state: t.optional(t.text({ maxLength: 512 })),\n resource: t.optional(t.text({ maxLength: 2048 })),\n});\n","import { t } from \"alepha\";\n\n/**\n * OAuth 2.1 authorization request query parameters (GET /oauth/authorize).\n */\nexport const authorizeQuerySchema = t.object({\n response_type: t.text(),\n client_id: t.text(),\n redirect_uri: t.text({ maxLength: 2048 }),\n code_challenge: t.text(),\n code_challenge_method: t.text(),\n scope: t.optional(t.text({ maxLength: 1024 })),\n state: t.optional(t.text({ maxLength: 512 })),\n resource: t.optional(t.text({ maxLength: 2048 })),\n});\n","import { t } from \"alepha\";\n\n/**\n * RFC 7591 Dynamic Client Registration request body.\n * Only the fields Alepha consumes are typed; unknown fields are ignored.\n */\nexport const registerClientBodySchema = t.object(\n {\n client_name: t.optional(t.text({ maxLength: 200 })),\n redirect_uris: t.array(t.text({ maxLength: 2048 }), { minItems: 1 }),\n scope: t.optional(t.text({ maxLength: 1024 })),\n grant_types: t.optional(t.array(t.text())),\n token_endpoint_auth_method: t.optional(t.text()),\n },\n { additionalProperties: true },\n);\n","import { t } from \"alepha\";\n\n/**\n * Body of a POST /oauth/token request. OAuth 2.1 mandates\n * application/x-www-form-urlencoded encoding (section 3.2.2). All fields are\n * optional at the schema level; the handler enforces the grant-specific\n * requirements and returns the appropriate OAuth error responses.\n */\nexport const tokenRequestBodySchema = t.object({\n grant_type: t.optional(t.text()),\n code: t.optional(t.text({ maxLength: 4096 })),\n client_id: t.optional(t.text()),\n redirect_uri: t.optional(t.text({ maxLength: 2048 })),\n code_verifier: t.optional(t.text({ maxLength: 256 })),\n refresh_token: t.optional(t.text({ maxLength: 4096 })),\n});\n","import { type Static, t } from \"alepha\";\nimport { $entity, db } from \"alepha/orm\";\n\n/**\n * A registered OAuth 2.1 client application.\n *\n * Rows are created by Dynamic Client Registration (RFC 7591) when an MCP\n * client (e.g. Claude) first connects. `source` records who created the\n * client; for DCR it is always `\"dcr\"` and `createdByUserId` is null until\n * a user completes an authorization.\n */\nexport const oauthClientEntity = $entity({\n name: \"oauth_clients\",\n schema: t.object({\n id: db.primaryKey(t.uuid()),\n createdAt: db.createdAt(),\n updatedAt: db.updatedAt(),\n clientId: t.text({ maxLength: 64 }),\n clientName: t.text({ maxLength: 200 }),\n redirectUris: db.default(t.array(t.text({ maxLength: 2048 })), []),\n scopes: db.default(t.array(t.text({ maxLength: 64 })), []),\n realm: t.text({ maxLength: 100 }),\n source: db.default(t.text({ maxLength: 16 }), \"dcr\"),\n createdByUserId: t.optional(t.uuid()),\n lastUsedAt: t.optional(t.datetime()),\n revokedAt: t.optional(t.datetime()),\n }),\n indexes: [{ columns: [\"clientId\"], unique: true }],\n});\n\nexport type OAuthClientEntity = Static<typeof oauthClientEntity.schema>;\n","import { createHash, randomUUID } from \"node:crypto\";\nimport { $inject, Alepha, AlephaError } from \"alepha\";\nimport { DateTimeProvider } from \"alepha/datetime\";\nimport { $logger } from \"alepha/logger\";\nimport { $repository } from \"alepha/orm\";\nimport {\n type IssuerPrimitive,\n JwtProvider,\n type UserAccount,\n} from \"alepha/security\";\nimport {\n type OAuthClientEntity,\n oauthClientEntity,\n} from \"../entities/oauthClientEntity.ts\";\n\nexport interface RegisterClientOptions {\n realm: string;\n clientName: string;\n redirectUris: string[];\n scopes: string[];\n source?: \"dcr\" | \"user\" | \"admin\";\n createdByUserId?: string;\n}\n\n/**\n * Core OAuth 2.1 service backing the authorization server.\n *\n * Responsibilities:\n * - Client registration (RFC 7591 Dynamic Client Registration) and lookup,\n * with exact-match redirect_uri validation.\n * - Stateless PKCE authorization codes: minting short-lived signed JWTs that\n * carry the grant, and verifying/consuming them (replay, expiry, client and\n * redirect_uri checks, S256 PKCE).\n * - Realm issuer registry: realms register an issuer + user loader so the\n * token endpoint can mint access tokens without depending on realm wiring.\n */\nexport class OAuthClientService {\n protected readonly alepha = $inject(Alepha);\n protected readonly dateTime = $inject(DateTimeProvider);\n protected readonly log = $logger();\n protected readonly repo = $repository(oauthClientEntity);\n protected readonly jwt = $inject(JwtProvider);\n\n /**\n * Codes already redeemed in this process. Single-use enforcement only\n * needs to cover the ~60s code lifetime, so a bounded in-memory set is\n * sufficient even on serverless — an expired code fails JWT verification\n * regardless.\n */\n protected readonly usedCodes = new Set<string>();\n\n /**\n * Registry of realm issuers used to mint access tokens. Populated by\n * `$realm` (via `registerIssuer`) so the OAuth module does not depend on the\n * realm wiring directly.\n */\n protected readonly issuers = new Map<\n string,\n {\n issuer: IssuerPrimitive;\n loadUser: (userId: string) => Promise<UserAccount>;\n }\n >();\n\n /**\n * Register a realm issuer and a user loader. Called by `$realm` so the\n * OAuth token endpoint can mint access tokens for that realm.\n */\n public registerIssuer(\n realm: string,\n issuer: IssuerPrimitive,\n loadUser: (userId: string) => Promise<UserAccount>,\n ): void {\n this.issuers.set(realm, { issuer, loadUser });\n }\n\n /**\n * Mint an access token for a consumed authorization-code grant, using the\n * issuer registered for `realm`. Throws if the realm has no issuer.\n */\n public async issueAccessToken(\n realm: string,\n grant: {\n userId: string;\n scopes: string[];\n resource?: string;\n clientId?: string;\n },\n ): Promise<{\n access_token: string;\n expires_in?: number;\n refresh_token?: string;\n }> {\n const entry = this.issuers.get(realm);\n if (!entry) {\n throw new AlephaError(`No issuer registered for realm '${realm}'`);\n }\n const user = await entry.loadUser(grant.userId);\n // Tag the session the issuer creates with the OAuth client, so it can\n // later be surfaced as a \"connected app\" and revoked individually.\n const tokens = await entry.issuer.createToken(user, undefined, {\n clientId: grant.clientId,\n });\n return {\n access_token: tokens.access_token,\n expires_in: tokens.expires_in,\n refresh_token: tokens.refresh_token,\n };\n }\n\n /**\n * Exchange a refresh token for a fresh access token (OAuth 2.1\n * `refresh_token` grant), using the issuer registered for `realm`. Lets an\n * MCP client stay connected for the refresh token's full lifetime without\n * re-running the authorization flow. Throws if the realm has no issuer or\n * the refresh token is invalid/expired.\n */\n public async refreshAccessToken(\n realm: string,\n refreshToken: string,\n ): Promise<{\n access_token: string;\n expires_in?: number;\n refresh_token?: string;\n }> {\n const entry = this.issuers.get(realm);\n if (!entry) {\n throw new AlephaError(`No issuer registered for realm '${realm}'`);\n }\n const { tokens } = await entry.issuer.refreshToken(refreshToken);\n return {\n access_token: tokens.access_token,\n expires_in: tokens.expires_in,\n refresh_token: tokens.refresh_token,\n };\n }\n\n /**\n * Register a new OAuth client. Used by the RFC 7591 DCR endpoint and,\n * later, by user/admin UIs (via the `source` field).\n */\n public async register(\n options: RegisterClientOptions,\n ): Promise<OAuthClientEntity> {\n if (options.redirectUris.length === 0) {\n throw new AlephaError(\"At least one redirect_uri is required\");\n }\n for (const uri of options.redirectUris) {\n if (!uri.startsWith(\"https://\") && !uri.startsWith(\"http://localhost\")) {\n throw new AlephaError(`Invalid redirect_uri: ${uri}`);\n }\n }\n\n const clientId = `mcp_${randomUUID().replace(/-/g, \"\")}`;\n const client = await this.repo.create({\n clientId,\n clientName: options.clientName || \"MCP Client\",\n redirectUris: options.redirectUris,\n scopes: options.scopes,\n realm: options.realm,\n source: options.source ?? \"dcr\",\n createdByUserId: options.createdByUserId,\n });\n\n this.log.info(\"OAuth client registered\", {\n clientId,\n source: client.source,\n });\n return client;\n }\n\n /**\n * Look up a client by its public `clientId`. Returns null if unknown.\n */\n public async findByClientId(\n clientId: string,\n ): Promise<OAuthClientEntity | null> {\n return (\n (await this.repo.findOne({ where: { clientId: { eq: clientId } } })) ??\n null\n );\n }\n\n /**\n * Exact-match redirect_uri check. OAuth 2.1 forbids substring/prefix\n * matching — the value must equal a registered URI byte-for-byte.\n */\n public isRedirectUriAllowed(\n client: OAuthClientEntity,\n redirectUri: string,\n ): boolean {\n return client.redirectUris.includes(redirectUri);\n }\n\n /**\n * Mint a stateless authorization code: a short-lived signed JWT\n * (`typ: \"oauth_code\"`) carrying the grant. No server-side code storage.\n */\n public async createAuthorizationCode(\n realm: string,\n grant: {\n userId: string;\n clientId: string;\n redirectUri: string;\n codeChallenge: string;\n scopes: string[];\n resource?: string;\n },\n ): Promise<string> {\n const iat = this.dateTime.now().unix();\n return this.jwt.create(\n {\n sub: grant.userId,\n client_id: grant.clientId,\n redirect_uri: grant.redirectUri,\n code_challenge: grant.codeChallenge,\n scopes: grant.scopes,\n resource: grant.resource,\n iat,\n exp: iat + 60,\n jti: randomUUID(),\n },\n realm,\n { header: { typ: \"oauth_code\" } },\n );\n }\n\n /**\n * Verify and atomically consume an authorization code. Throws on expiry,\n * replay, client/redirect mismatch, or PKCE failure.\n */\n public async consumeAuthorizationCode(\n realm: string,\n code: string,\n check: { clientId: string; redirectUri: string; codeVerifier: string },\n ): Promise<{ userId: string; scopes: string[]; resource?: string }> {\n const { result } = await this.jwt.parse(code, realm, {\n typ: \"oauth_code\",\n });\n const payload = result.payload as Record<string, unknown>;\n\n const jti = payload.jti as string;\n if (this.usedCodes.has(jti)) {\n throw new AlephaError(\"Authorization code already used\");\n }\n if (payload.client_id !== check.clientId) {\n throw new AlephaError(\"client_id mismatch\");\n }\n if (payload.redirect_uri !== check.redirectUri) {\n throw new AlephaError(\"redirect_uri mismatch\");\n }\n\n const computed = createHash(\"sha256\")\n .update(check.codeVerifier)\n .digest(\"base64url\");\n if (computed !== payload.code_challenge) {\n throw new AlephaError(\"PKCE verification failed\");\n }\n\n this.usedCodes.add(jti);\n return {\n userId: payload.sub as string,\n scopes: (payload.scopes as string[]) ?? [],\n resource: payload.resource as string | undefined,\n };\n }\n}\n","import { $atom, $inject, $state, t } from \"alepha\";\nimport { $logger } from \"alepha/logger\";\nimport { $route } from \"alepha/server\";\nimport { renderConsentPage } from \"../helpers/consentPage.ts\";\nimport {\n buildAuthorizationServerMetadata,\n buildProtectedResourceMetadata,\n} from \"../helpers/oauthMetadata.ts\";\nimport { authorizeDecisionBodySchema } from \"../schemas/authorizeDecisionBodySchema.ts\";\nimport { authorizeQuerySchema } from \"../schemas/authorizeQuerySchema.ts\";\nimport { registerClientBodySchema } from \"../schemas/registerClientBodySchema.ts\";\nimport { tokenRequestBodySchema } from \"../schemas/tokenRequestBodySchema.ts\";\nimport { OAuthClientService } from \"../services/OAuthClientService.ts\";\n\n/**\n * Configuration for the OAuth authorization server.\n * `realm` is the issuer realm whose JWTs are minted as access tokens;\n * `resource` is the path of the protected MCP endpoint;\n * `loginPath` is the app-level login page unauthenticated users are\n * redirected to from the authorize endpoint.\n */\nexport const oauthOptions = $atom({\n name: \"alepha.api.oauth.options\",\n description: \"Configuration for the OAuth authorization server.\",\n schema: t.object({\n realm: t.text({ default: \"users\" }),\n resource: t.text({ default: \"/mcp\" }),\n loginPath: t.text({ default: \"/login\" }),\n }),\n default: { realm: \"users\", resource: \"/mcp\", loginPath: \"/login\" },\n});\n\n/**\n * OAuth 2.1 authorization server endpoints: discovery metadata and\n * RFC 7591 dynamic client registration. Authorize/token routes are added\n * separately.\n */\nexport class OAuthController {\n protected readonly log = $logger();\n protected readonly options = $state(oauthOptions);\n protected readonly clients = $inject(OAuthClientService);\n\n /**\n * Absolute origin of the current request, e.g. https://app.com.\n */\n protected baseUrl(url: URL): string {\n return `${url.protocol}//${url.host}`;\n }\n\n metadata = $route({\n method: \"GET\",\n path: \"/.well-known/oauth-authorization-server\",\n handler: ({ url, reply }) => {\n reply.headers[\"content-type\"] = \"application/json\";\n reply.body = JSON.stringify(\n buildAuthorizationServerMetadata(this.baseUrl(url)),\n );\n },\n });\n\n protectedResource = $route({\n method: \"GET\",\n path: \"/.well-known/oauth-protected-resource\",\n handler: ({ url, reply }) => {\n const base = this.baseUrl(url);\n reply.headers[\"content-type\"] = \"application/json\";\n reply.body = JSON.stringify(\n buildProtectedResourceMetadata(base, `${base}${this.options.resource}`),\n );\n },\n });\n\n register = $route({\n method: \"POST\",\n path: \"/oauth/register\",\n schema: { body: registerClientBodySchema },\n handler: async ({ body, reply }) => {\n const client = await this.clients.register({\n realm: this.options.realm,\n clientName: body.client_name ?? \"MCP Client\",\n redirectUris: body.redirect_uris,\n scopes: body.scope ? body.scope.split(\" \") : [\"mcp\"],\n source: \"dcr\",\n });\n reply.status = 201;\n reply.headers[\"content-type\"] = \"application/json\";\n reply.body = JSON.stringify({\n client_id: client.clientId,\n client_id_issued_at: Math.floor(\n new Date(client.createdAt).getTime() / 1000,\n ),\n client_name: client.clientName,\n redirect_uris: client.redirectUris,\n grant_types: [\"authorization_code\"],\n token_endpoint_auth_method: \"none\",\n });\n },\n });\n\n /**\n * GET /oauth/authorize — OAuth 2.1 authorization request. If the user\n * has no session, redirect to the realm login page with a return URL.\n * If authenticated, render the consent screen.\n */\n authorize = $route({\n method: \"GET\",\n path: \"/oauth/authorize\",\n schema: { query: authorizeQuerySchema },\n use: [],\n handler: async ({ query, user, url, reply }) => {\n if (query.response_type !== \"code\") {\n reply.status = 400;\n reply.body = \"unsupported response_type\";\n return;\n }\n if (query.code_challenge_method !== \"S256\") {\n reply.status = 400;\n reply.body = \"code_challenge_method must be S256\";\n return;\n }\n const client = await this.clients.findByClientId(query.client_id);\n if (!client || client.revokedAt) {\n reply.status = 400;\n reply.body = \"unknown client_id\";\n return;\n }\n if (!this.clients.isRedirectUriAllowed(client, query.redirect_uri)) {\n reply.status = 400;\n reply.body = \"redirect_uri not registered\";\n return;\n }\n if (!user) {\n const returnTo = encodeURIComponent(url.pathname + url.search);\n reply.redirect(\n `${this.options.loginPath}?redirect_uri=${returnTo}`,\n 302,\n );\n return;\n }\n reply.headers[\"content-type\"] = \"text/html; charset=utf-8\";\n reply.body = renderConsentPage({\n clientName: client.clientName,\n userName: user.name ?? user.email ?? \"your account\",\n scopes: query.scope ? query.scope.split(\" \") : client.scopes,\n hidden: {\n response_type: query.response_type,\n client_id: query.client_id,\n redirect_uri: query.redirect_uri,\n code_challenge: query.code_challenge,\n code_challenge_method: query.code_challenge_method,\n scope: query.scope ?? \"\",\n state: query.state ?? \"\",\n resource: query.resource ?? \"\",\n },\n });\n },\n });\n\n /**\n * POST /oauth/authorize — consent decision. On \"allow\", mint an\n * authorization code and redirect back to the client's redirect_uri.\n *\n * CSRF: this route carries no CSRF token and relies solely on the session\n * cookie to identify the user. This is a deliberate MVP/MCP tradeoff — a\n * forged consent submit can still only issue an authorization code to an\n * already-registered client, and that code is bound by PKCE, so the\n * attacker cannot redeem it without the matching code_verifier.\n */\n authorizeDecision = $route({\n method: \"POST\",\n path: \"/oauth/authorize\",\n schema: { body: authorizeDecisionBodySchema },\n use: [],\n handler: async ({ body, user, reply }) => {\n if (!user) {\n reply.status = 401;\n reply.body = \"authentication required\";\n return;\n }\n const client = await this.clients.findByClientId(body.client_id);\n if (\n !client ||\n client.revokedAt ||\n !this.clients.isRedirectUriAllowed(client, body.redirect_uri)\n ) {\n reply.status = 400;\n reply.body = \"invalid client\";\n return;\n }\n const redirect = new URL(body.redirect_uri);\n if (body.decision !== \"allow\") {\n redirect.searchParams.set(\"error\", \"access_denied\");\n if (body.state) redirect.searchParams.set(\"state\", body.state);\n reply.redirect(redirect.toString(), 302);\n return;\n }\n const code = await this.clients.createAuthorizationCode(\n this.options.realm,\n {\n userId: user.id,\n clientId: body.client_id,\n redirectUri: body.redirect_uri,\n codeChallenge: body.code_challenge,\n scopes: body.scope ? body.scope.split(\" \") : client.scopes,\n resource: body.resource || undefined,\n },\n );\n redirect.searchParams.set(\"code\", code);\n if (body.state) redirect.searchParams.set(\"state\", body.state);\n reply.redirect(redirect.toString(), 302);\n },\n });\n\n /**\n * POST /oauth/token — supports the `authorization_code` grant (verifies\n * PKCE, mints an access token via the realm issuer) and the\n * `refresh_token` grant (exchanges a refresh token for a fresh access\n * token, so a client stays connected without re-running the flow).\n */\n token = $route({\n method: \"POST\",\n path: \"/oauth/token\",\n schema: { body: tokenRequestBodySchema },\n use: [],\n handler: async ({ body, reply }) => {\n reply.headers[\"content-type\"] = \"application/json\";\n try {\n if (body.grant_type === \"authorization_code\") {\n const grant = await this.clients.consumeAuthorizationCode(\n this.options.realm,\n body.code ?? \"\",\n {\n clientId: body.client_id ?? \"\",\n redirectUri: body.redirect_uri ?? \"\",\n codeVerifier: body.code_verifier ?? \"\",\n },\n );\n const tokens = await this.clients.issueAccessToken(\n this.options.realm,\n { ...grant, clientId: body.client_id ?? \"\" },\n );\n reply.body = JSON.stringify({\n access_token: tokens.access_token,\n token_type: \"Bearer\",\n expires_in: tokens.expires_in,\n refresh_token: tokens.refresh_token,\n scope: grant.scopes.join(\" \"),\n });\n return;\n }\n\n if (body.grant_type === \"refresh_token\") {\n const tokens = await this.clients.refreshAccessToken(\n this.options.realm,\n body.refresh_token ?? \"\",\n );\n reply.body = JSON.stringify({\n access_token: tokens.access_token,\n token_type: \"Bearer\",\n expires_in: tokens.expires_in,\n refresh_token: tokens.refresh_token,\n });\n return;\n }\n\n reply.status = 400;\n reply.body = JSON.stringify({ error: \"unsupported_grant_type\" });\n } catch (e) {\n this.log.warn(\"OAuth token exchange failed\", e);\n reply.status = 400;\n reply.body = JSON.stringify({ error: \"invalid_grant\" });\n }\n },\n });\n}\n","import { $module } from \"alepha\";\nimport { OAuthController } from \"./controllers/OAuthController.ts\";\nimport { OAuthClientService } from \"./services/OAuthClientService.ts\";\n\nexport {\n OAuthController,\n oauthOptions,\n} from \"./controllers/OAuthController.ts\";\nexport type { OAuthClientEntity } from \"./entities/oauthClientEntity.ts\";\nexport { oauthClientEntity } from \"./entities/oauthClientEntity.ts\";\nexport type { RegisterClientOptions } from \"./services/OAuthClientService.ts\";\nexport { OAuthClientService } from \"./services/OAuthClientService.ts\";\n\n/**\n * OAuth 2.1 authorization server module for MCP.\n *\n * **Features:**\n * - OAuth 2.1 authorization code flow with PKCE (RFC 7636)\n * - Dynamic Client Registration (RFC 7591)\n * - Authorization server metadata discovery (RFC 8414)\n * - Stateless authorization codes (short-lived signed JWTs)\n * - Single-use code enforcement\n *\n * **Integration:**\n * Register the module and configure the realm + protected resource path:\n *\n * ```ts\n * const app = Alepha.create()\n * .with(AlephaOAuth)\n * .set(oauthOptions, { realm: \"users\", resource: \"/mcp\" });\n * ```\n *\n * @module alepha.api.oauth\n */\nexport const AlephaOAuth = $module({\n name: \"alepha.api.oauth\",\n services: [OAuthClientService, OAuthController],\n});\n"],"mappings":"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"}
|