|
| 1 | +// 授权页上显示的站点名。和后端 sso.clients 表对应(后端还会再校验 client 和回跳地址) |
| 2 | +export const SSO_CLIENTS: Record<string, string> = { |
| 3 | + holocard: "HoloCard", |
| 4 | + "holocard-staging": "HoloCard(staging)", |
| 5 | +}; |
| 6 | + |
| 7 | +export const SSO_PENDING_KEY = "ih:sso:pending"; |
| 8 | +const PENDING_TTL_MS = 10 * 60 * 1000; |
| 9 | + |
| 10 | +const SSO_PARAMS = [ |
| 11 | + "client_id", |
| 12 | + "redirect_uri", |
| 13 | + "state", |
| 14 | + "code_challenge", |
| 15 | + "code_challenge_method", |
| 16 | +] as const; |
| 17 | + |
| 18 | +export type SsoRequest = Record<(typeof SSO_PARAMS)[number], string>; |
| 19 | + |
| 20 | +// client 表必须用 Object.hasOwn 查:?client_id=__proto__ / constructor 会命中原型链 |
| 21 | +export function parseSsoRequest( |
| 22 | + source: Record<string, unknown>, |
| 23 | +): SsoRequest | null { |
| 24 | + const req = {} as SsoRequest; |
| 25 | + for (const key of SSO_PARAMS) { |
| 26 | + const value = source[key]; |
| 27 | + if (typeof value !== "string" || !value) return null; |
| 28 | + req[key] = value; |
| 29 | + } |
| 30 | + if (req.code_challenge_method !== "S256") return null; |
| 31 | + if (!Object.hasOwn(SSO_CLIENTS, req.client_id)) return null; |
| 32 | + return req; |
| 33 | +} |
| 34 | + |
| 35 | +export function savePending(storage: Storage, req: SsoRequest) { |
| 36 | + storage.setItem( |
| 37 | + SSO_PENDING_KEY, |
| 38 | + JSON.stringify({ ...req, savedAt: Date.now() }), |
| 39 | + ); |
| 40 | +} |
| 41 | + |
| 42 | +// 取出即删;过期、损坏、校验不过都按没有处理 |
| 43 | +export function takePending(storage: Storage): SsoRequest | null { |
| 44 | + const raw = storage.getItem(SSO_PENDING_KEY); |
| 45 | + if (raw === null) return null; |
| 46 | + storage.removeItem(SSO_PENDING_KEY); |
| 47 | + try { |
| 48 | + const pending = JSON.parse(raw); |
| 49 | + const age = Date.now() - pending.savedAt; |
| 50 | + return age >= 0 && age < PENDING_TTL_MS ? parseSsoRequest(pending) : null; |
| 51 | + } catch { |
| 52 | + return null; |
| 53 | + } |
| 54 | +} |
| 55 | + |
| 56 | +export function authorizePath(req: SsoRequest): string { |
| 57 | + return `/sso/authorize?${new URLSearchParams(req)}`; |
| 58 | +} |
| 59 | + |
| 60 | +// 跳转目标只认后端返回的 data.redirect(redirect_uri 由后端按登记值逐字校验)。 |
| 61 | +// 失败一律返回 null,调用方不得退回去用 query 里的 redirect_uri。 |
| 62 | +export async function requestSsoRedirect( |
| 63 | + req: SsoRequest, |
| 64 | + token: string, |
| 65 | +): Promise<string | null> { |
| 66 | + try { |
| 67 | + const res = await fetch("/oauth/sso/code", { |
| 68 | + method: "POST", |
| 69 | + headers: { "Content-Type": "application/json", satoken: token }, |
| 70 | + body: JSON.stringify({ |
| 71 | + clientId: req.client_id, |
| 72 | + redirectUri: req.redirect_uri, |
| 73 | + state: req.state, |
| 74 | + codeChallenge: req.code_challenge, |
| 75 | + codeChallengeMethod: req.code_challenge_method, |
| 76 | + }), |
| 77 | + }); |
| 78 | + const body = await res.json(); |
| 79 | + const redirect = body?.data?.redirect; |
| 80 | + return res.ok && |
| 81 | + body.success === true && |
| 82 | + typeof redirect === "string" && |
| 83 | + redirect |
| 84 | + ? redirect |
| 85 | + : null; |
| 86 | + } catch { |
| 87 | + return null; |
| 88 | + } |
| 89 | +} |
0 commit comments