Skip to content

Commit 27518ef

Browse files
committed
release: 发布 v0.3.0
1 parent d6a3b70 commit 27518ef

11 files changed

Lines changed: 471 additions & 179 deletions

File tree

apps/api/src/index.ts

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ interface Env {
66
DB?: D1Database;
77
CACHE?: KVNamespace;
88
IMAGES?: R2Bucket;
9+
AUTH_MODE?: string;
10+
SSO_ISSUER?: string;
11+
SSO_CLIENT_ID?: string;
12+
SSO_ENTITLEMENT_TENANT_ID?: string;
13+
SSO_REQUIRED_ENTITLEMENT_KEY?: string;
914
GITHUB_CLIENT_ID?: string;
1015
GITHUB_CLIENT_SECRET?: string;
1116
AUTH_SECRET?: string;
@@ -90,6 +95,13 @@ type AuthSession = {
9095
exp: number;
9196
};
9297

98+
type SsoUserInfo = {
99+
sub: string;
100+
tid?: string;
101+
gaid?: string;
102+
email?: string;
103+
};
104+
93105
type GithubUser = {
94106
id: number;
95107
login: string;
@@ -211,6 +223,66 @@ const redirectWithCookies = (location: string, cookies: string[] = []): Response
211223

212224
const allowHeaderIdentity = (env: Env): boolean => env.ALLOW_HEADER_IDENTITY !== "0";
213225

226+
type AuthMode = "legacy" | "hybrid" | "sso";
227+
228+
const resolveAuthMode = (env: Env): AuthMode => {
229+
const mode = String(env.AUTH_MODE ?? "legacy").trim().toLowerCase();
230+
if (mode === "hybrid" || mode === "sso") return mode;
231+
return "legacy";
232+
};
233+
234+
const readBearerToken = (request: Request): string | null => {
235+
const raw = request.headers.get("authorization")?.trim() ?? "";
236+
if (!/^bearer\s+/i.test(raw)) return null;
237+
const token = raw.replace(/^bearer\s+/i, "").trim();
238+
return token || null;
239+
};
240+
241+
const isJwtLike = (token: string): boolean => {
242+
const parts = token.split(".");
243+
return parts.length === 3 && parts.every((part) => part.length > 0);
244+
};
245+
246+
type SsoIdentityResult =
247+
| { status: "ok"; identity: Identity; user: SsoUserInfo }
248+
| { status: "unavailable" }
249+
| { status: "invalid" };
250+
251+
const readSsoIdentity = async (request: Request, env: Env): Promise<SsoIdentityResult> => {
252+
const issuer = String(env.SSO_ISSUER ?? "").trim().replace(/\/+$/, "");
253+
if (!issuer) return { status: "unavailable" };
254+
const token = readBearerToken(request);
255+
if (!token || !isJwtLike(token)) return { status: "unavailable" };
256+
257+
const headerDeviceId = request.headers.get("x-device-id")?.trim() ?? "";
258+
const response = await fetch(`${issuer}/userinfo`, {
259+
headers: {
260+
authorization: `Bearer ${token}`,
261+
accept: "application/json"
262+
}
263+
}).catch(() => null);
264+
265+
if (!response?.ok) return { status: "invalid" };
266+
267+
const payload = (await response.json().catch(() => null)) as Partial<SsoUserInfo> | null;
268+
const sub = typeof payload?.sub === "string" ? payload.sub.trim() : "";
269+
if (!sub) return { status: "invalid" };
270+
271+
return {
272+
status: "ok",
273+
identity: {
274+
userId: sub,
275+
deviceId: headerDeviceId || "web_browser"
276+
},
277+
user: {
278+
sub,
279+
tid: typeof payload?.tid === "string" ? payload.tid : undefined,
280+
gaid: typeof payload?.gaid === "string" ? payload.gaid : undefined,
281+
email: typeof payload?.email === "string" ? payload.email : undefined
282+
}
283+
};
284+
};
285+
214286
const hmacSign = async (secret: string, message: string): Promise<string> => {
215287
const enc = new TextEncoder();
216288
const key = await crypto.subtle.importKey(
@@ -311,8 +383,20 @@ const getDbOrError = (env: Env): D1Database | Response => {
311383
};
312384

313385
const getIdentity = async (request: Request, env: Env): Promise<Identity | Response> => {
314-
const session = (await readSession(request, env)) ?? (await readBearerSession(request, env));
386+
const authMode = resolveAuthMode(env);
315387
const headerDeviceId = request.headers.get("x-device-id")?.trim() ?? "";
388+
389+
if (authMode !== "legacy") {
390+
const sso = await readSsoIdentity(request, env);
391+
if (sso.status === "ok") {
392+
return sso.identity;
393+
}
394+
if (authMode === "sso") {
395+
return fail("AUTH_REQUIRED", "SSO sign-in is required.", 401);
396+
}
397+
}
398+
399+
const session = (await readSession(request, env)) ?? (await readBearerSession(request, env));
316400
if (session) {
317401
return { userId: session.sub, deviceId: headerDeviceId || "web_browser" };
318402
}
@@ -326,7 +410,7 @@ const getIdentity = async (request: Request, env: Env): Promise<Identity | Respo
326410
if (!userId || !deviceId) {
327411
return fail(
328412
"IDENTITY_REQUIRED",
329-
"Sign in with GitHub or provide headers x-user-id and x-device-id.",
413+
"Sign in with SSO or provide headers x-user-id and x-device-id.",
330414
400
331415
);
332416
}
@@ -1683,10 +1767,25 @@ const getGithubRedirectUri = (request: Request, env: Env): string => {
16831767
return `${url.origin}/v1/auth/github/callback`;
16841768
};
16851769

1770+
const getSsoIssuer = (env: Env): string => String(env.SSO_ISSUER ?? "").trim().replace(/\/+$/, "");
1771+
1772+
const getSsoClientId = (env: Env): string => String(env.SSO_CLIENT_ID ?? "misonote-paste-web").trim() || "misonote-paste-web";
1773+
1774+
const isHttpUrl = (value: string): boolean => /^https?:\/\//i.test(value.trim());
1775+
16861776
const hasGithubAuthConfig = (env: Env): boolean =>
16871777
Boolean(env.GITHUB_CLIENT_ID?.trim() && env.GITHUB_CLIENT_SECRET?.trim() && env.AUTH_SECRET?.trim());
16881778

1779+
const ensureGithubAuthEnabled = (env: Env): Response | null => {
1780+
if (resolveAuthMode(env) === "legacy") {
1781+
return null;
1782+
}
1783+
return fail("AUTH_METHOD_DISABLED", "GitHub auth is disabled. Use Cloudflare SSO.", 404);
1784+
};
1785+
16891786
const handleAuthGithubStart = async (request: Request, env: Env): Promise<Response> => {
1787+
const disabled = ensureGithubAuthEnabled(env);
1788+
if (disabled) return disabled;
16901789
if (!hasGithubAuthConfig(env)) {
16911790
return fail("AUTH_CONFIG_MISSING", "GitHub auth is not configured.", 500);
16921791
}
@@ -1715,6 +1814,8 @@ const handleAuthGithubStart = async (request: Request, env: Env): Promise<Respon
17151814
};
17161815

17171816
const handleAuthGithubCallback = async (request: Request, env: Env): Promise<Response> => {
1817+
const disabled = ensureGithubAuthEnabled(env);
1818+
if (disabled) return disabled;
17181819
if (!hasGithubAuthConfig(env)) {
17191820
return fail("AUTH_CONFIG_MISSING", "GitHub auth is not configured.", 500);
17201821
}
@@ -1808,6 +1909,8 @@ const handleAuthGithubCallback = async (request: Request, env: Env): Promise<Res
18081909
};
18091910

18101911
const handleAuthGithubDeviceStart = async (env: Env): Promise<Response> => {
1912+
const disabled = ensureGithubAuthEnabled(env);
1913+
if (disabled) return disabled;
18111914
if (!hasGithubAuthConfig(env)) {
18121915
return fail("AUTH_CONFIG_MISSING", "GitHub auth is not configured.", 500);
18131916
}
@@ -1854,6 +1957,8 @@ const handleAuthGithubDeviceStart = async (env: Env): Promise<Response> => {
18541957
};
18551958

18561959
const handleAuthGithubDevicePoll = async (request: Request, env: Env): Promise<Response> => {
1960+
const disabled = ensureGithubAuthEnabled(env);
1961+
if (disabled) return disabled;
18571962
if (!hasGithubAuthConfig(env)) {
18581963
return fail("AUTH_CONFIG_MISSING", "GitHub auth is not configured.", 500);
18591964
}
@@ -1950,7 +2055,123 @@ const handleAuthGithubDevicePoll = async (request: Request, env: Env): Promise<R
19502055
});
19512056
};
19522057

2058+
const handleAuthSsoToken = async (request: Request, env: Env): Promise<Response> => {
2059+
const issuer = getSsoIssuer(env);
2060+
if (!issuer) {
2061+
return fail("SSO_CONFIG_MISSING", "SSO issuer is not configured.", 500);
2062+
}
2063+
2064+
const parsed = await parseJson<{
2065+
grantType?: "authorization_code" | "refresh_token";
2066+
code?: string;
2067+
codeVerifier?: string;
2068+
redirectUri?: string;
2069+
refreshToken?: string;
2070+
}>(request);
2071+
if (parsed instanceof Response) {
2072+
return parsed;
2073+
}
2074+
2075+
const grantType = parsed.grantType ?? "authorization_code";
2076+
if (grantType !== "authorization_code" && grantType !== "refresh_token") {
2077+
return fail("INVALID_GRANT_TYPE", "grantType must be authorization_code or refresh_token", 400);
2078+
}
2079+
2080+
const form = new URLSearchParams();
2081+
form.set("grant_type", grantType);
2082+
form.set("client_id", getSsoClientId(env));
2083+
2084+
if (grantType === "authorization_code") {
2085+
const code = String(parsed.code ?? "").trim();
2086+
const codeVerifier = String(parsed.codeVerifier ?? "").trim();
2087+
const redirectUri = String(parsed.redirectUri ?? "").trim();
2088+
if (!code || !codeVerifier || !redirectUri) {
2089+
return fail("INVALID_SSO_CODE_EXCHANGE", "code, codeVerifier, redirectUri are required", 400);
2090+
}
2091+
if (!isHttpUrl(redirectUri)) {
2092+
return fail("INVALID_REDIRECT_URI", "redirectUri must be http(s)", 400);
2093+
}
2094+
form.set("code", code);
2095+
form.set("code_verifier", codeVerifier);
2096+
form.set("redirect_uri", redirectUri);
2097+
} else {
2098+
const refreshToken = String(parsed.refreshToken ?? "").trim();
2099+
if (!refreshToken) {
2100+
return fail("INVALID_REFRESH_TOKEN", "refreshToken is required", 400);
2101+
}
2102+
form.set("refresh_token", refreshToken);
2103+
}
2104+
2105+
const response = await fetch(`${issuer}/token`, {
2106+
method: "POST",
2107+
headers: {
2108+
accept: "application/json",
2109+
"content-type": "application/x-www-form-urlencoded"
2110+
},
2111+
body: form.toString()
2112+
});
2113+
2114+
const payload = (await response.json().catch(() => ({}))) as {
2115+
access_token?: string;
2116+
refresh_token?: string;
2117+
expires_in?: number;
2118+
token_type?: string;
2119+
scope?: string;
2120+
error_description?: string;
2121+
statusMessage?: string;
2122+
};
2123+
if (!response.ok) {
2124+
return fail(
2125+
"SSO_TOKEN_EXCHANGE_FAILED",
2126+
payload.statusMessage?.trim() || payload.error_description?.trim() || `sso token exchange failed (${response.status})`,
2127+
response.status >= 400 && response.status < 600 ? response.status : 502
2128+
);
2129+
}
2130+
2131+
const accessToken = String(payload.access_token ?? "").trim();
2132+
if (!accessToken) {
2133+
return fail("SSO_TOKEN_MISSING", "sso access token missing", 502);
2134+
}
2135+
2136+
return ok({
2137+
tokenType: payload.token_type || "Bearer",
2138+
accessToken,
2139+
refreshToken: String(payload.refresh_token ?? "").trim() || null,
2140+
expiresIn: Number.isFinite(Number(payload.expires_in)) ? Number(payload.expires_in) : 300,
2141+
scope: payload.scope || null
2142+
});
2143+
};
2144+
19532145
const handleAuthMe = async (request: Request, env: Env): Promise<Response> => {
2146+
const authMode = resolveAuthMode(env);
2147+
if (authMode !== "legacy") {
2148+
const sso = await readSsoIdentity(request, env);
2149+
if (sso.status === "ok") {
2150+
return ok({
2151+
authenticated: true,
2152+
user: {
2153+
userId: sso.user.sub,
2154+
githubLogin: sso.user.gaid ?? sso.user.sub,
2155+
githubId: 0
2156+
},
2157+
headerIdentityEnabled: authMode !== "sso" && allowHeaderIdentity(env),
2158+
authConfigured: true,
2159+
authMode,
2160+
authSource: "sso"
2161+
});
2162+
}
2163+
if (authMode === "sso") {
2164+
return ok({
2165+
authenticated: false,
2166+
user: null,
2167+
headerIdentityEnabled: false,
2168+
authConfigured: true,
2169+
authMode,
2170+
authSource: null
2171+
});
2172+
}
2173+
}
2174+
19542175
const session = (await readSession(request, env)) ?? (await readBearerSession(request, env));
19552176
return ok({
19562177
authenticated: Boolean(session),
@@ -1962,7 +2183,9 @@ const handleAuthMe = async (request: Request, env: Env): Promise<Response> => {
19622183
}
19632184
: null,
19642185
headerIdentityEnabled: allowHeaderIdentity(env),
1965-
authConfigured: hasGithubAuthConfig(env)
2186+
authConfigured: hasGithubAuthConfig(env),
2187+
authMode,
2188+
authSource: session ? "legacy" : null
19662189
});
19672190
};
19682191

@@ -2046,6 +2269,10 @@ export default {
20462269
return handleAuthGithubDevicePoll(request, env);
20472270
}
20482271

2272+
if (request.method === "POST" && path === "/v1/auth/sso/token") {
2273+
return handleAuthSsoToken(request, env);
2274+
}
2275+
20492276
if (request.method === "POST" && path === "/v1/auth/logout") {
20502277
return handleAuthLogout(request);
20512278
}

apps/api/wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ routes = [
1212
[vars]
1313
APP_NAME = "paste"
1414
API_VERSION = "v1"
15+
AUTH_MODE = "sso"
16+
SSO_ISSUER = "https://cloudflare-sso.pages.dev"
17+
SSO_CLIENT_ID = "misonote-paste-web"
18+
SSO_ENTITLEMENT_TENANT_ID = "tenant-misonote"
19+
SSO_REQUIRED_ENTITLEMENT_KEY = "membership.all_apps"
1520
ALLOW_HEADER_IDENTITY = "0"
1621

1722
[[d1_databases]]

0 commit comments

Comments
 (0)