dineway 0.1.3 → 0.1.4
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/package.json +6 -3
- package/src/astro/routes/PluginRegistry.tsx +21 -0
- package/src/astro/routes/admin.astro +83 -0
- package/src/astro/routes/api/admin/allowed-domains/[domain].ts +112 -0
- package/src/astro/routes/api/admin/allowed-domains/index.ts +108 -0
- package/src/astro/routes/api/admin/api-tokens/[id].ts +40 -0
- package/src/astro/routes/api/admin/api-tokens/index.ts +68 -0
- package/src/astro/routes/api/admin/bylines/[id]/index.ts +87 -0
- package/src/astro/routes/api/admin/bylines/index.ts +72 -0
- package/src/astro/routes/api/admin/comments/[id]/status.ts +120 -0
- package/src/astro/routes/api/admin/comments/[id].ts +64 -0
- package/src/astro/routes/api/admin/comments/bulk.ts +42 -0
- package/src/astro/routes/api/admin/comments/counts.ts +30 -0
- package/src/astro/routes/api/admin/comments/index.ts +46 -0
- package/src/astro/routes/api/admin/hooks/exclusive/[hookName].ts +91 -0
- package/src/astro/routes/api/admin/hooks/exclusive/index.ts +51 -0
- package/src/astro/routes/api/admin/oauth-clients/[id].ts +110 -0
- package/src/astro/routes/api/admin/oauth-clients/index.ts +71 -0
- package/src/astro/routes/api/admin/plugins/[id]/disable.ts +39 -0
- package/src/astro/routes/api/admin/plugins/[id]/enable.ts +39 -0
- package/src/astro/routes/api/admin/plugins/[id]/index.ts +38 -0
- package/src/astro/routes/api/admin/plugins/[id]/uninstall.ts +48 -0
- package/src/astro/routes/api/admin/plugins/[id]/update.ts +59 -0
- package/src/astro/routes/api/admin/plugins/index.ts +32 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts +62 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts +33 -0
- package/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +64 -0
- package/src/astro/routes/api/admin/plugins/marketplace/index.ts +38 -0
- package/src/astro/routes/api/admin/plugins/updates.ts +28 -0
- package/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts +33 -0
- package/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts +62 -0
- package/src/astro/routes/api/admin/themes/marketplace/index.ts +45 -0
- package/src/astro/routes/api/admin/users/[id]/disable.ts +69 -0
- package/src/astro/routes/api/admin/users/[id]/enable.ts +48 -0
- package/src/astro/routes/api/admin/users/[id]/index.ts +146 -0
- package/src/astro/routes/api/admin/users/[id]/send-recovery.ts +72 -0
- package/src/astro/routes/api/admin/users/index.ts +66 -0
- package/src/astro/routes/api/auth/dev-bypass.ts +139 -0
- package/src/astro/routes/api/auth/invite/accept.ts +52 -0
- package/src/astro/routes/api/auth/invite/complete.ts +86 -0
- package/src/astro/routes/api/auth/invite/index.ts +99 -0
- package/src/astro/routes/api/auth/logout.ts +40 -0
- package/src/astro/routes/api/auth/magic-link/send.ts +89 -0
- package/src/astro/routes/api/auth/magic-link/verify.ts +71 -0
- package/src/astro/routes/api/auth/me.ts +60 -0
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +221 -0
- package/src/astro/routes/api/auth/oauth/[provider].ts +120 -0
- package/src/astro/routes/api/auth/passkey/[id].ts +124 -0
- package/src/astro/routes/api/auth/passkey/index.ts +54 -0
- package/src/astro/routes/api/auth/passkey/options.ts +84 -0
- package/src/astro/routes/api/auth/passkey/register/options.ts +88 -0
- package/src/astro/routes/api/auth/passkey/register/verify.ts +119 -0
- package/src/astro/routes/api/auth/passkey/verify.ts +68 -0
- package/src/astro/routes/api/auth/signup/complete.ts +87 -0
- package/src/astro/routes/api/auth/signup/request.ts +77 -0
- package/src/astro/routes/api/auth/signup/verify.ts +53 -0
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +311 -0
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +28 -0
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +54 -0
- package/src/astro/routes/api/content/[collection]/[id]/duplicate.ts +61 -0
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +33 -0
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +107 -0
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +56 -0
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +54 -0
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +31 -0
- package/src/astro/routes/api/content/[collection]/[id]/schedule.ts +105 -0
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +140 -0
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +30 -0
- package/src/astro/routes/api/content/[collection]/[id]/unpublish.ts +56 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +137 -0
- package/src/astro/routes/api/content/[collection]/index.ts +59 -0
- package/src/astro/routes/api/content/[collection]/trash.ts +33 -0
- package/src/astro/routes/api/dashboard.ts +32 -0
- package/src/astro/routes/api/dev/emails.ts +36 -0
- package/src/astro/routes/api/import/probe.ts +47 -0
- package/src/astro/routes/api/import/wordpress/analyze.ts +531 -0
- package/src/astro/routes/api/import/wordpress/execute.ts +296 -0
- package/src/astro/routes/api/import/wordpress/media.ts +338 -0
- package/src/astro/routes/api/import/wordpress/prepare.ts +181 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +393 -0
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +111 -0
- package/src/astro/routes/api/import/wordpress-plugin/callback.ts +58 -0
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +357 -0
- package/src/astro/routes/api/manifest.ts +63 -0
- package/src/astro/routes/api/mcp.ts +124 -0
- package/src/astro/routes/api/media/[id]/confirm.ts +93 -0
- package/src/astro/routes/api/media/[id].ts +145 -0
- package/src/astro/routes/api/media/file/[...key].ts +79 -0
- package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +86 -0
- package/src/astro/routes/api/media/providers/[providerId]/index.ts +111 -0
- package/src/astro/routes/api/media/providers/index.ts +30 -0
- package/src/astro/routes/api/media/upload-url.ts +137 -0
- package/src/astro/routes/api/media.ts +202 -0
- package/src/astro/routes/api/menus/[name]/items.ts +87 -0
- package/src/astro/routes/api/menus/[name]/reorder.ts +33 -0
- package/src/astro/routes/api/menus/[name].ts +65 -0
- package/src/astro/routes/api/menus/index.ts +47 -0
- package/src/astro/routes/api/oauth/authorize.ts +417 -0
- package/src/astro/routes/api/oauth/device/authorize.ts +45 -0
- package/src/astro/routes/api/oauth/device/code.ts +55 -0
- package/src/astro/routes/api/oauth/device/token.ts +69 -0
- package/src/astro/routes/api/oauth/token/refresh.ts +38 -0
- package/src/astro/routes/api/oauth/token/revoke.ts +38 -0
- package/src/astro/routes/api/oauth/token.ts +184 -0
- package/src/astro/routes/api/openapi.json.ts +32 -0
- package/src/astro/routes/api/plugins/[pluginId]/[...path].ts +92 -0
- package/src/astro/routes/api/redirects/404s/index.ts +72 -0
- package/src/astro/routes/api/redirects/404s/summary.ts +33 -0
- package/src/astro/routes/api/redirects/[id].ts +84 -0
- package/src/astro/routes/api/redirects/index.ts +52 -0
- package/src/astro/routes/api/revisions/[revisionId]/index.ts +29 -0
- package/src/astro/routes/api/revisions/[revisionId]/restore.ts +62 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +76 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +52 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +32 -0
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +80 -0
- package/src/astro/routes/api/schema/collections/index.ts +47 -0
- package/src/astro/routes/api/schema/index.ts +109 -0
- package/src/astro/routes/api/schema/orphans/[slug].ts +36 -0
- package/src/astro/routes/api/schema/orphans/index.ts +26 -0
- package/src/astro/routes/api/search/enable.ts +64 -0
- package/src/astro/routes/api/search/index.ts +51 -0
- package/src/astro/routes/api/search/rebuild.ts +72 -0
- package/src/astro/routes/api/search/stats.ts +35 -0
- package/src/astro/routes/api/search/suggest.ts +49 -0
- package/src/astro/routes/api/sections/[slug].ts +84 -0
- package/src/astro/routes/api/sections/index.ts +52 -0
- package/src/astro/routes/api/settings/email.ts +150 -0
- package/src/astro/routes/api/settings.ts +67 -0
- package/src/astro/routes/api/setup/admin-verify.ts +102 -0
- package/src/astro/routes/api/setup/admin.ts +96 -0
- package/src/astro/routes/api/setup/dev-bypass.ts +200 -0
- package/src/astro/routes/api/setup/dev-reset.ts +40 -0
- package/src/astro/routes/api/setup/index.ts +127 -0
- package/src/astro/routes/api/setup/status.ts +122 -0
- package/src/astro/routes/api/snapshot.ts +76 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +95 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +69 -0
- package/src/astro/routes/api/taxonomies/index.ts +59 -0
- package/src/astro/routes/api/themes/preview.ts +78 -0
- package/src/astro/routes/api/typegen.ts +114 -0
- package/src/astro/routes/api/well-known/auth.ts +69 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +45 -0
- package/src/astro/routes/api/well-known/oauth-protected-resource.ts +38 -0
- package/src/astro/routes/api/widget-areas/[name]/reorder.ts +72 -0
- package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +127 -0
- package/src/astro/routes/api/widget-areas/[name]/widgets.ts +80 -0
- package/src/astro/routes/api/widget-areas/[name].ts +87 -0
- package/src/astro/routes/api/widget-areas/index.ts +99 -0
- package/src/astro/routes/api/widget-components.ts +22 -0
- package/src/astro/routes/robots.txt.ts +81 -0
- package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
- package/src/astro/routes/sitemap.xml.ts +92 -0
- package/src/components/Break.astro +45 -0
- package/src/components/Button.astro +71 -0
- package/src/components/Buttons.astro +49 -0
- package/src/components/Code.astro +59 -0
- package/src/components/Columns.astro +59 -0
- package/src/components/CommentForm.astro +315 -0
- package/src/components/Comments.astro +232 -0
- package/src/components/Cover.astro +128 -0
- package/src/components/DinewayBodyEnd.astro +32 -0
- package/src/components/DinewayBodyStart.astro +32 -0
- package/src/components/DinewayHead.astro +53 -0
- package/src/components/DinewayImage.astro +178 -0
- package/src/components/DinewayMedia.astro +167 -0
- package/src/components/Embed.astro +128 -0
- package/src/components/File.astro +122 -0
- package/src/components/Gallery.astro +93 -0
- package/src/components/HtmlBlock.astro +33 -0
- package/src/components/Image.astro +178 -0
- package/src/components/InlineEditor.astro +27 -0
- package/src/components/InlinePortableTextEditor.tsx +1937 -0
- package/src/components/LiveSearch.astro +614 -0
- package/src/components/PortableText.astro +51 -0
- package/src/components/Pullquote.astro +51 -0
- package/src/components/Table.astro +108 -0
- package/src/components/WidgetArea.astro +22 -0
- package/src/components/WidgetRenderer.astro +72 -0
- package/src/components/index.ts +116 -0
- package/src/components/marks/Link.astro +31 -0
- package/src/components/marks/StrikeThrough.astro +7 -0
- package/src/components/marks/Subscript.astro +7 -0
- package/src/components/marks/Superscript.astro +7 -0
- package/src/components/marks/Underline.astro +7 -0
- package/src/components/widgets/Archives.astro +65 -0
- package/src/components/widgets/Categories.astro +35 -0
- package/src/components/widgets/RecentPosts.astro +51 -0
- package/src/components/widgets/Search.astro +18 -0
- package/src/components/widgets/Tags.astro +38 -0
- package/src/ui.ts +75 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET/POST /_dineway/oauth/authorize
|
|
3
|
+
*
|
|
4
|
+
* OAuth 2.1 Authorization Endpoint. Handles both the consent page (GET)
|
|
5
|
+
* and consent submission (POST).
|
|
6
|
+
*
|
|
7
|
+
* GET: Renders an HTML consent page showing which client is requesting
|
|
8
|
+
* access and which scopes are being requested.
|
|
9
|
+
* POST: Processes the user's decision (approve/deny) and redirects
|
|
10
|
+
* to the client's redirect_uri with an authorization code or error.
|
|
11
|
+
*
|
|
12
|
+
* Requires an authenticated session (not token auth). If unauthenticated,
|
|
13
|
+
* redirects to login with a return URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { APIRoute } from "astro";
|
|
17
|
+
|
|
18
|
+
import { escapeHtml } from "#api/escape.js";
|
|
19
|
+
import {
|
|
20
|
+
buildDeniedRedirect,
|
|
21
|
+
handleAuthorizationApproval,
|
|
22
|
+
validateRedirectUri,
|
|
23
|
+
} from "#api/handlers/oauth-authorization.js";
|
|
24
|
+
import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js";
|
|
25
|
+
import { getPublicOrigin } from "#api/public-url.js";
|
|
26
|
+
import { VALID_SCOPES } from "#auth/api-tokens.js";
|
|
27
|
+
|
|
28
|
+
export const prerender = false;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// CSRF (SEC-18): Double-submit cookie pattern
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const CSRF_COOKIE_NAME = "dineway_oauth_csrf";
|
|
35
|
+
|
|
36
|
+
/** Generate a 32-byte random token as hex. */
|
|
37
|
+
function generateCsrfToken(): string {
|
|
38
|
+
const bytes = new Uint8Array(32);
|
|
39
|
+
crypto.getRandomValues(bytes);
|
|
40
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build the Set-Cookie header value for the CSRF token. */
|
|
44
|
+
function csrfCookieHeader(token: string, request: Request, siteUrl?: string): string {
|
|
45
|
+
// SameSite=Strict prevents cross-site form submission.
|
|
46
|
+
// HttpOnly: the token value is embedded in the form hidden field server-side,
|
|
47
|
+
// so JS never needs to read the cookie. HttpOnly adds defense-in-depth.
|
|
48
|
+
// Secure is set when:
|
|
49
|
+
// - siteUrl is configured and uses https (proxy case — request may be http internally), OR
|
|
50
|
+
// - the actual request is over https (non-proxy case, preserve existing behavior — H-2)
|
|
51
|
+
const isSecure = siteUrl
|
|
52
|
+
? siteUrl.startsWith("https:")
|
|
53
|
+
: new URL(request.url).protocol === "https:";
|
|
54
|
+
const secure = isSecure ? "; Secure" : "";
|
|
55
|
+
return `${CSRF_COOKIE_NAME}=${token}; Path=/_dineway/api/oauth/authorize; HttpOnly; SameSite=Strict${secure}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Extract the CSRF token from the request's cookies. */
|
|
59
|
+
function getCsrfCookie(request: Request): string | null {
|
|
60
|
+
const cookieHeader = request.headers.get("Cookie");
|
|
61
|
+
if (!cookieHeader) return null;
|
|
62
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE_NAME}=([^;]+)`));
|
|
63
|
+
return match?.[1] ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Human-readable scope labels
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const SCOPE_LABELS: Record<string, string> = {
|
|
71
|
+
"content:read": "Read content (posts, pages, etc.)",
|
|
72
|
+
"content:write": "Create, edit, and delete content",
|
|
73
|
+
"media:read": "View media files",
|
|
74
|
+
"media:write": "Upload and manage media files",
|
|
75
|
+
"schema:read": "View collection schemas",
|
|
76
|
+
"schema:write": "Create and modify collection schemas",
|
|
77
|
+
admin: "Full administrative access",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// GET: Render consent page
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export const GET: APIRoute = async ({ url, request, locals }) => {
|
|
85
|
+
const { dineway, user } = locals;
|
|
86
|
+
|
|
87
|
+
// Validate required OAuth params before rendering
|
|
88
|
+
const clientId = url.searchParams.get("client_id");
|
|
89
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
90
|
+
const responseType = url.searchParams.get("response_type");
|
|
91
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
92
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
93
|
+
const scope = url.searchParams.get("scope");
|
|
94
|
+
const state = url.searchParams.get("state");
|
|
95
|
+
|
|
96
|
+
// Basic validation — detailed validation happens on POST
|
|
97
|
+
if (!clientId || !redirectUri || responseType !== "code" || !codeChallenge) {
|
|
98
|
+
return new Response(
|
|
99
|
+
renderErrorPage("Invalid authorization request. Missing required parameters."),
|
|
100
|
+
{
|
|
101
|
+
status: 400,
|
|
102
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (codeChallengeMethod && codeChallengeMethod !== "S256") {
|
|
108
|
+
return new Response(renderErrorPage("Only S256 code challenge method is supported."), {
|
|
109
|
+
status: 400,
|
|
110
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Validate client_id is registered and redirect_uri is in the allowlist.
|
|
115
|
+
// This check happens BEFORE authentication so we never redirect to an
|
|
116
|
+
// unregistered URI (even for the login redirect, we only redirect to our
|
|
117
|
+
// own login page, not to the client's redirect_uri).
|
|
118
|
+
if (dineway?.db) {
|
|
119
|
+
const client = await lookupOAuthClient(dineway.db, clientId);
|
|
120
|
+
if (!client) {
|
|
121
|
+
return new Response(renderErrorPage("Unknown client application."), {
|
|
122
|
+
status: 400,
|
|
123
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const clientUriError = validateClientRedirectUri(redirectUri, client.redirectUris);
|
|
128
|
+
if (clientUriError) {
|
|
129
|
+
return new Response(renderErrorPage("The redirect URI is not registered for this client."), {
|
|
130
|
+
status: 400,
|
|
131
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If not authenticated, redirect to login with return URL
|
|
137
|
+
if (!user) {
|
|
138
|
+
const loginUrl = new URL("/_dineway/admin/login", getPublicOrigin(url, dineway?.config));
|
|
139
|
+
loginUrl.searchParams.set("redirect", url.pathname + url.search);
|
|
140
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Parse and validate scopes
|
|
144
|
+
const validSet = new Set<string>(VALID_SCOPES);
|
|
145
|
+
const requestedScopes = (scope ?? "")
|
|
146
|
+
.split(" ")
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.filter((s) => validSet.has(s));
|
|
149
|
+
|
|
150
|
+
if (requestedScopes.length === 0) {
|
|
151
|
+
return new Response(renderErrorPage("No valid scopes requested."), {
|
|
152
|
+
status: 400,
|
|
153
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SEC-18: Generate CSRF token for the consent form (double-submit cookie pattern)
|
|
158
|
+
const csrfToken = generateCsrfToken();
|
|
159
|
+
|
|
160
|
+
// Render the consent page
|
|
161
|
+
const html = renderConsentPage({
|
|
162
|
+
clientId,
|
|
163
|
+
scopes: requestedScopes,
|
|
164
|
+
redirectUri,
|
|
165
|
+
responseType,
|
|
166
|
+
codeChallenge,
|
|
167
|
+
codeChallengeMethod: codeChallengeMethod ?? "S256",
|
|
168
|
+
state: state ?? "",
|
|
169
|
+
resource: url.searchParams.get("resource") ?? "",
|
|
170
|
+
userName: user.name ?? user.email,
|
|
171
|
+
csrfToken,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return new Response(html, {
|
|
175
|
+
headers: {
|
|
176
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
177
|
+
"Set-Cookie": csrfCookieHeader(csrfToken, request, getPublicOrigin(url, dineway?.config)),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// POST: Process consent
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
187
|
+
const { dineway, user } = locals;
|
|
188
|
+
|
|
189
|
+
if (!dineway?.db) {
|
|
190
|
+
return new Response(renderErrorPage("Dineway is not initialized."), {
|
|
191
|
+
status: 500,
|
|
192
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!user) {
|
|
197
|
+
return new Response(renderErrorPage("Authentication required."), {
|
|
198
|
+
status: 401,
|
|
199
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const formData = await request.formData();
|
|
204
|
+
const field = (name: string, fallback = ""): string => {
|
|
205
|
+
const v = formData.get(name);
|
|
206
|
+
return typeof v === "string" ? v : fallback;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// SEC-18: Validate CSRF token (double-submit cookie pattern).
|
|
210
|
+
// The form includes a hidden csrf_token field; the cookie has the same value.
|
|
211
|
+
// An attacker cannot read the cookie to forge the form field (HttpOnly + SameSite=Strict).
|
|
212
|
+
const formCsrf = field("csrf_token");
|
|
213
|
+
const cookieCsrf = getCsrfCookie(request);
|
|
214
|
+
const csrfError = new Response(
|
|
215
|
+
renderErrorPage("Invalid or missing CSRF token. Please try again."),
|
|
216
|
+
{ status: 403, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
|
217
|
+
);
|
|
218
|
+
if (!formCsrf || !cookieCsrf) return csrfError;
|
|
219
|
+
|
|
220
|
+
// Constant-time comparison: hash both values to fixed-length 32-byte digests,
|
|
221
|
+
// then XOR every byte pair. This avoids non-standard timing-safe helpers and
|
|
222
|
+
// works across the supported Node/Web Crypto path.
|
|
223
|
+
// The SHA-256 pre-hash ensures fixed length, eliminating length-leaking.
|
|
224
|
+
const csrfEncoder = new TextEncoder();
|
|
225
|
+
const [csrfHashA, csrfHashB] = await Promise.all([
|
|
226
|
+
crypto.subtle.digest("SHA-256", csrfEncoder.encode(formCsrf)),
|
|
227
|
+
crypto.subtle.digest("SHA-256", csrfEncoder.encode(cookieCsrf)),
|
|
228
|
+
]);
|
|
229
|
+
const a = new Uint8Array(csrfHashA);
|
|
230
|
+
const b = new Uint8Array(csrfHashB);
|
|
231
|
+
let diff = 0;
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- tsgo needs these
|
|
233
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
|
234
|
+
if (diff !== 0) return csrfError;
|
|
235
|
+
|
|
236
|
+
const action = field("action");
|
|
237
|
+
const redirectUri = field("redirect_uri");
|
|
238
|
+
const state = field("state") || undefined;
|
|
239
|
+
|
|
240
|
+
if (!redirectUri) {
|
|
241
|
+
return new Response(renderErrorPage("Missing redirect_uri."), {
|
|
242
|
+
status: 400,
|
|
243
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Validate redirect_uri scheme/host before using it for any redirect
|
|
248
|
+
const uriError = validateRedirectUri(redirectUri);
|
|
249
|
+
if (uriError) {
|
|
250
|
+
return new Response(renderErrorPage(escapeHtml(uriError)), {
|
|
251
|
+
status: 400,
|
|
252
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// User denied — SEC-44: validate redirect_uri against client's registered URIs
|
|
257
|
+
// before redirecting, to prevent open redirect on the deny path.
|
|
258
|
+
if (action === "deny") {
|
|
259
|
+
const clientId = field("client_id");
|
|
260
|
+
if (!clientId) {
|
|
261
|
+
return new Response(renderErrorPage("Missing client_id."), {
|
|
262
|
+
status: 400,
|
|
263
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const client = await lookupOAuthClient(dineway.db, clientId);
|
|
268
|
+
if (!client) {
|
|
269
|
+
return new Response(renderErrorPage("Unknown client application."), {
|
|
270
|
+
status: 400,
|
|
271
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const clientUriError = validateClientRedirectUri(redirectUri, client.redirectUris);
|
|
276
|
+
if (clientUriError) {
|
|
277
|
+
return new Response(renderErrorPage("The redirect URI is not registered for this client."), {
|
|
278
|
+
status: 400,
|
|
279
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const denyUrl = buildDeniedRedirect(redirectUri, state);
|
|
284
|
+
return Response.redirect(denyUrl, 302);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// User approved — process the authorization
|
|
288
|
+
const result = await handleAuthorizationApproval(dineway.db, user.id, user.role, {
|
|
289
|
+
response_type: field("response_type", "code"),
|
|
290
|
+
client_id: field("client_id"),
|
|
291
|
+
redirect_uri: redirectUri,
|
|
292
|
+
scope: field("scope"),
|
|
293
|
+
state,
|
|
294
|
+
code_challenge: field("code_challenge"),
|
|
295
|
+
code_challenge_method: field("code_challenge_method", "S256"),
|
|
296
|
+
resource: field("resource") || undefined,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (!result.success) {
|
|
300
|
+
const errMsg = result.error?.message ?? "Authorization failed";
|
|
301
|
+
// On error, redirect back with error params — use generic description to avoid
|
|
302
|
+
// leaking internal error details to the (already-validated) redirect target
|
|
303
|
+
try {
|
|
304
|
+
const errorUrl = new URL(redirectUri);
|
|
305
|
+
errorUrl.searchParams.set("error", "server_error");
|
|
306
|
+
errorUrl.searchParams.set("error_description", "Authorization failed");
|
|
307
|
+
if (state) errorUrl.searchParams.set("state", state);
|
|
308
|
+
return Response.redirect(errorUrl.toString(), 302);
|
|
309
|
+
} catch {
|
|
310
|
+
return new Response(renderErrorPage(escapeHtml(errMsg)), {
|
|
311
|
+
status: 400,
|
|
312
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return Response.redirect(result.data.redirect_url, 302);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// HTML rendering
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function renderConsentPage(params: {
|
|
325
|
+
clientId: string;
|
|
326
|
+
scopes: string[];
|
|
327
|
+
redirectUri: string;
|
|
328
|
+
responseType: string;
|
|
329
|
+
codeChallenge: string;
|
|
330
|
+
codeChallengeMethod: string;
|
|
331
|
+
state: string;
|
|
332
|
+
resource: string;
|
|
333
|
+
userName: string;
|
|
334
|
+
csrfToken: string;
|
|
335
|
+
}): string {
|
|
336
|
+
const scopeList = params.scopes
|
|
337
|
+
.map((s) => {
|
|
338
|
+
const label = SCOPE_LABELS[s] ?? s;
|
|
339
|
+
return `<li>${escapeHtml(label)}</li>`;
|
|
340
|
+
})
|
|
341
|
+
.join("\n");
|
|
342
|
+
|
|
343
|
+
return `<!DOCTYPE html>
|
|
344
|
+
<html lang="en">
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8">
|
|
347
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
348
|
+
<title>Authorize Application — Dineway</title>
|
|
349
|
+
<style>
|
|
350
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
351
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #e5e5e5; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 1rem; }
|
|
352
|
+
.card { background: #171717; border: 1px solid #262626; border-radius: 12px; max-width: 420px; width: 100%; padding: 2rem; }
|
|
353
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
354
|
+
.client-id { color: #a3a3a3; font-size: 0.875rem; word-break: break-all; margin-bottom: 1.5rem; }
|
|
355
|
+
.user { color: #a3a3a3; font-size: 0.875rem; margin-bottom: 1rem; }
|
|
356
|
+
h2 { font-size: 0.875rem; font-weight: 500; color: #a3a3a3; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.75rem; }
|
|
357
|
+
ul { list-style: none; margin-bottom: 1.5rem; }
|
|
358
|
+
li { padding: 0.5rem 0; border-bottom: 1px solid #262626; font-size: 0.875rem; }
|
|
359
|
+
li:last-child { border-bottom: none; }
|
|
360
|
+
.actions { display: flex; gap: 0.75rem; }
|
|
361
|
+
button { flex: 1; padding: 0.625rem 1rem; border-radius: 8px; border: none; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
|
|
362
|
+
.approve { background: #2563eb; color: white; }
|
|
363
|
+
.approve:hover { background: #1d4ed8; }
|
|
364
|
+
.deny { background: #262626; color: #e5e5e5; }
|
|
365
|
+
.deny:hover { background: #333; }
|
|
366
|
+
</style>
|
|
367
|
+
</head>
|
|
368
|
+
<body>
|
|
369
|
+
<div class="card">
|
|
370
|
+
<h1>Authorize Application</h1>
|
|
371
|
+
<p class="client-id">${escapeHtml(params.clientId)}</p>
|
|
372
|
+
<p class="user">Signed in as <strong>${escapeHtml(params.userName)}</strong></p>
|
|
373
|
+
<h2>Permissions requested</h2>
|
|
374
|
+
<ul>${scopeList}</ul>
|
|
375
|
+
<form method="POST">
|
|
376
|
+
<input type="hidden" name="csrf_token" value="${escapeHtml(params.csrfToken)}">
|
|
377
|
+
<input type="hidden" name="response_type" value="${escapeHtml(params.responseType)}">
|
|
378
|
+
<input type="hidden" name="client_id" value="${escapeHtml(params.clientId)}">
|
|
379
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(params.redirectUri)}">
|
|
380
|
+
<input type="hidden" name="scope" value="${escapeHtml(params.scopes.join(" "))}">
|
|
381
|
+
<input type="hidden" name="state" value="${escapeHtml(params.state)}">
|
|
382
|
+
<input type="hidden" name="code_challenge" value="${escapeHtml(params.codeChallenge)}">
|
|
383
|
+
<input type="hidden" name="code_challenge_method" value="${escapeHtml(params.codeChallengeMethod)}">
|
|
384
|
+
<input type="hidden" name="resource" value="${escapeHtml(params.resource)}">
|
|
385
|
+
<div class="actions">
|
|
386
|
+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
|
387
|
+
<button type="submit" name="action" value="approve" class="approve">Approve</button>
|
|
388
|
+
</div>
|
|
389
|
+
</form>
|
|
390
|
+
</div>
|
|
391
|
+
</body>
|
|
392
|
+
</html>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function renderErrorPage(message: string): string {
|
|
396
|
+
return `<!DOCTYPE html>
|
|
397
|
+
<html lang="en">
|
|
398
|
+
<head>
|
|
399
|
+
<meta charset="utf-8">
|
|
400
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
401
|
+
<title>Authorization Error — Dineway</title>
|
|
402
|
+
<style>
|
|
403
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
404
|
+
body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0a; color: #e5e5e5; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 1rem; }
|
|
405
|
+
.card { background: #171717; border: 1px solid #262626; border-radius: 12px; max-width: 420px; width: 100%; padding: 2rem; }
|
|
406
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: #ef4444; }
|
|
407
|
+
p { font-size: 0.875rem; color: #a3a3a3; }
|
|
408
|
+
</style>
|
|
409
|
+
</head>
|
|
410
|
+
<body>
|
|
411
|
+
<div class="card">
|
|
412
|
+
<h1>Authorization Error</h1>
|
|
413
|
+
<p>${escapeHtml(message)}</p>
|
|
414
|
+
</div>
|
|
415
|
+
</body>
|
|
416
|
+
</html>`;
|
|
417
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_dineway/api/oauth/device/authorize
|
|
3
|
+
*
|
|
4
|
+
* User submits the user code after logging in via the browser.
|
|
5
|
+
* This endpoint requires authentication (the user must be logged in).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/// <reference types="dineway/locals" />
|
|
9
|
+
|
|
10
|
+
import type { APIRoute } from "astro";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { apiError, handleError, unwrapResult } from "#api/error.js";
|
|
14
|
+
import { handleDeviceAuthorize } from "#api/handlers/device-flow.js";
|
|
15
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
16
|
+
|
|
17
|
+
export const prerender = false;
|
|
18
|
+
|
|
19
|
+
const authorizeSchema = z.object({
|
|
20
|
+
user_code: z.string().min(1),
|
|
21
|
+
action: z.enum(["approve", "deny"]).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
25
|
+
const { dineway } = locals;
|
|
26
|
+
const { user } = locals;
|
|
27
|
+
|
|
28
|
+
if (!dineway?.db) {
|
|
29
|
+
return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!user) {
|
|
33
|
+
return apiError("NOT_AUTHENTICATED", "Authentication required", 401);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const body = await parseBody(request, authorizeSchema);
|
|
38
|
+
if (isParseError(body)) return body;
|
|
39
|
+
|
|
40
|
+
const result = await handleDeviceAuthorize(dineway.db, user.id, user.role, body);
|
|
41
|
+
return unwrapResult(result);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return handleError(error, "Failed to authorize device", "AUTHORIZE_ERROR");
|
|
44
|
+
}
|
|
45
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_dineway/api/oauth/device/code
|
|
3
|
+
*
|
|
4
|
+
* Issue a device code + user code for the OAuth Device Flow.
|
|
5
|
+
* This is an unauthenticated endpoint (the CLI doesn't have a token yet).
|
|
6
|
+
*
|
|
7
|
+
* Rate limited: 10 requests per minute per IP.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { APIRoute } from "astro";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import { apiError, handleError, unwrapResult } from "#api/error.js";
|
|
14
|
+
import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
|
|
15
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
16
|
+
import { getPublicOrigin } from "#api/public-url.js";
|
|
17
|
+
import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
|
|
18
|
+
|
|
19
|
+
export const prerender = false;
|
|
20
|
+
|
|
21
|
+
const deviceCodeSchema = z.object({
|
|
22
|
+
client_id: z.string().optional(),
|
|
23
|
+
scope: z.string().optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
27
|
+
const { dineway } = locals;
|
|
28
|
+
|
|
29
|
+
if (!dineway?.db) {
|
|
30
|
+
return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const body = await parseBody(request, deviceCodeSchema);
|
|
35
|
+
if (isParseError(body)) return body;
|
|
36
|
+
|
|
37
|
+
// Rate limit: 10 requests per 60 seconds per IP
|
|
38
|
+
const ip = getClientIp(request);
|
|
39
|
+
const rateLimit = await checkRateLimit(dineway.db, ip, "device/code", 10, 60);
|
|
40
|
+
if (!rateLimit.allowed) {
|
|
41
|
+
return rateLimitResponse(60);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build the verification URI — device page lives inside the admin SPA
|
|
45
|
+
const verificationUri = new URL(
|
|
46
|
+
"/_dineway/admin/device",
|
|
47
|
+
getPublicOrigin(url, dineway?.config),
|
|
48
|
+
).toString();
|
|
49
|
+
|
|
50
|
+
const result = await handleDeviceCodeRequest(dineway.db, body, verificationUri);
|
|
51
|
+
return unwrapResult(result);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return handleError(error, "Failed to create device code", "DEVICE_CODE_ERROR");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_dineway/api/oauth/device/token
|
|
3
|
+
*
|
|
4
|
+
* CLI polls this endpoint to exchange a device code for tokens.
|
|
5
|
+
* Returns RFC 8628 error codes during the polling phase.
|
|
6
|
+
* This is an unauthenticated endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Rate limited: 12 requests per minute per IP.
|
|
9
|
+
* Also enforces RFC 8628 slow_down: if polled faster than the interval,
|
|
10
|
+
* responds with { error: "slow_down", interval: N } and increases the interval by 5s.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { APIRoute } from "astro";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
import { apiError, handleError, unwrapResult } from "#api/error.js";
|
|
17
|
+
import { handleDeviceTokenExchange } from "#api/handlers/device-flow.js";
|
|
18
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
19
|
+
import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
|
|
20
|
+
|
|
21
|
+
export const prerender = false;
|
|
22
|
+
|
|
23
|
+
const deviceTokenSchema = z.object({
|
|
24
|
+
device_code: z.string().min(1),
|
|
25
|
+
grant_type: z.string().min(1),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
29
|
+
const { dineway } = locals;
|
|
30
|
+
|
|
31
|
+
if (!dineway?.db) {
|
|
32
|
+
return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const body = await parseBody(request, deviceTokenSchema);
|
|
37
|
+
if (isParseError(body)) return body;
|
|
38
|
+
|
|
39
|
+
// Rate limit: 12 requests per 60 seconds per IP
|
|
40
|
+
const ip = getClientIp(request);
|
|
41
|
+
const rateLimit = await checkRateLimit(dineway.db, ip, "device/token", 12, 60);
|
|
42
|
+
if (!rateLimit.allowed) {
|
|
43
|
+
return rateLimitResponse(60);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await handleDeviceTokenExchange(dineway.db, body);
|
|
47
|
+
|
|
48
|
+
// RFC 8628 requires specific error format for device flow errors
|
|
49
|
+
// RFC 6749 §5.1 requires Cache-Control: no-store + Pragma: no-cache on token responses
|
|
50
|
+
if (!result.success && result.deviceFlowError) {
|
|
51
|
+
const errorBody: { error: string; interval?: number } = { error: result.deviceFlowError };
|
|
52
|
+
if (result.deviceFlowInterval !== undefined) {
|
|
53
|
+
errorBody.interval = result.deviceFlowInterval;
|
|
54
|
+
}
|
|
55
|
+
return Response.json(errorBody, {
|
|
56
|
+
status: 400,
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
"Cache-Control": "no-store",
|
|
60
|
+
Pragma: "no-cache",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return unwrapResult(result);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return handleError(error, "Failed to exchange device code", "TOKEN_EXCHANGE_ERROR");
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_dineway/api/oauth/token/refresh
|
|
3
|
+
*
|
|
4
|
+
* Exchange a refresh token for a new access token.
|
|
5
|
+
* This is an unauthenticated endpoint (the caller presents the refresh token).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { APIRoute } from "astro";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { apiError, handleError, unwrapResult } from "#api/error.js";
|
|
12
|
+
import { handleTokenRefresh } from "#api/handlers/device-flow.js";
|
|
13
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
14
|
+
|
|
15
|
+
export const prerender = false;
|
|
16
|
+
|
|
17
|
+
const refreshSchema = z.object({
|
|
18
|
+
refresh_token: z.string().min(1),
|
|
19
|
+
grant_type: z.string().min(1),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
23
|
+
const { dineway } = locals;
|
|
24
|
+
|
|
25
|
+
if (!dineway?.db) {
|
|
26
|
+
return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const body = await parseBody(request, refreshSchema);
|
|
31
|
+
if (isParseError(body)) return body;
|
|
32
|
+
|
|
33
|
+
const result = await handleTokenRefresh(dineway.db, body);
|
|
34
|
+
return unwrapResult(result);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return handleError(error, "Failed to refresh token", "TOKEN_REFRESH_ERROR");
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_dineway/api/oauth/token/revoke
|
|
3
|
+
*
|
|
4
|
+
* Revoke an access or refresh token (RFC 7009).
|
|
5
|
+
* Always returns 200, even for invalid tokens.
|
|
6
|
+
* This is an unauthenticated endpoint (the caller presents the token to revoke).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { apiError, handleError, unwrapResult } from "#api/error.js";
|
|
13
|
+
import { handleTokenRevoke } from "#api/handlers/device-flow.js";
|
|
14
|
+
import { isParseError, parseBody } from "#api/parse.js";
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
const revokeSchema = z.object({
|
|
19
|
+
token: z.string().min(1),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
23
|
+
const { dineway } = locals;
|
|
24
|
+
|
|
25
|
+
if (!dineway?.db) {
|
|
26
|
+
return apiError("NOT_CONFIGURED", "Dineway is not initialized", 500);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const body = await parseBody(request, revokeSchema);
|
|
31
|
+
if (isParseError(body)) return body;
|
|
32
|
+
|
|
33
|
+
const result = await handleTokenRevoke(dineway.db, body);
|
|
34
|
+
return unwrapResult(result);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return handleError(error, "Failed to revoke token", "TOKEN_REVOKE_ERROR");
|
|
37
|
+
}
|
|
38
|
+
};
|