g1t/apps/web/app/lib/token-scopes.ts

163 lines6,637 bytesCodeBlame
1/**
2 * Choosing what a token, an application or a workspace's token may do:
3 * the checklist's fields, read into the scopes identity stores, and the
4 * words settings use to show them again.
5 *
6 * Tokens are classic: a token reaches whatever its owner can, and its
7 * scopes say what it may do there. The form posts one `scope` field per
8 * ticked box. A higher level of a resource includes the lower ones
9 * (`issues:write` gives `issues:read`), so only the highest ticked level
10 * of each resource is stored.
11 */
12
13import {
14 PRESETS,
15 SCOPES,
16 SCOPE_RESOURCES,
17 levelsOf,
18 parseScopes,
19 presetScopes,
20 scopeIncludes,
21 scopeLevel,
22 scopeResource,
23 OAUTH_DEFAULT_SCOPES,
24 type PresetId,
25 type Scope,
26 type ScopeLevel,
27 type ScopeResource,
28} from "@g1t/contracts/scopes";
29
30const RANK: Record<ScopeLevel, number> = { read: 0, write: 1, run: 2, admin: 3 };
31
32/** Anything with FormData's getters, so tests can pass a plain map. */
33export type FormLike = { get(name: string): unknown; getAll(name: string): unknown[] };
34
35/** The fewest scopes that give the same access: the highest level per resource, in table order. */
36export function normalizeScopes(scopes: readonly string[]): Scope[] {
37 const top = new Map<ScopeResource, Scope>();
38 for (const scope of parseScopes(scopes.join(" "))) {
39 const held = top.get(scopeResource(scope));
40 if (!held || RANK[scopeLevel(scope)] > RANK[scopeLevel(held)]) top.set(scopeResource(scope), scope);
41 }
42 return SCOPES.map((row) => row.scope).filter((scope) => top.get(scopeResource(scope)) === scope);
43}
44
45/** Every scope's top level: what full access ticks. */
46export function everyScope(): Scope[] {
47 return SCOPE_RESOURCES.map(({ resource }) => {
48 const levels = levelsOf(resource);
49 return `${resource}:${levels[levels.length - 1]}` as Scope;
50 });
51}
52
53/**
54 * Whether `scope` is given by another ticked box of its resource at a
55 * higher level. The checklist shows it ticked and greyed out.
56 */
57export function impliedBy(ticked: readonly Scope[], scope: Scope): Scope | null {
58 return ticked.find((held) => held !== scope && scopeIncludes(held, scope)) ?? null;
59}
60
61/** The preset a set of scopes is exactly, if any. Null scopes are full access. */
62export function matchingPreset(scopes: readonly string[] | null): PresetId | null {
63 if (scopes === null) return "full";
64 const mine = normalizeScopes(scopes).join(" ");
65 for (const preset of PRESETS) {
66 const theirs = presetScopes(preset.id);
67 if (theirs && normalizeScopes(theirs).join(" ") === mine) return preset.id;
68 }
69 return null;
70}
71
72export function presetLabel(id: PresetId): string {
73 return PRESETS.find((preset) => preset.id === id)?.label ?? id;
74}
75
76/** How a token or grant's access reads in a list. */
77export function accessSummary(holder: { scopes: readonly string[] | null; legacy: boolean }): string {
78 if (holder.scopes === null) return holder.legacy ? "Legacy · full access" : "Full access";
79 const preset = matchingPreset(holder.scopes);
80 if (preset) return presetLabel(preset);
81 const count = normalizeScopes(holder.scopes).length;
82 if (count === 0) return "No scopes";
83 return count === 1 ? "1 scope" : `${count} scopes`;
84}
85
86/** Whether a set of scopes gives anything an admin level gives. */
87export function hasDangerous(scopes: readonly string[] | null): boolean {
88 return scopes === null || normalizeScopes(scopes).some((scope) => scopeLevel(scope) === "admin");
89}
90
91export type GrantInput = { scopes: string[] | null };
92
93export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
94
95/**
96 * The scopes ticked on the checklist: `preset` = `full` is full access
97 * (null); otherwise every `scope` box, unknown names left out.
98 */
99export function scopesFromForm(form: FormLike, options: { allowFull?: boolean } = {}): Parsed<string[] | null> {
100 if (options.allowFull !== false && form.get("preset") === "full") return { ok: true, value: null };
101 const scopes = normalizeScopes(form.getAll("scope").map(String));
102 if (scopes.length === 0) return { ok: false, error: "Tick at least one scope." };
103 return { ok: true, value: scopes };
104}
105
106/** What the scope checklist posts, ready for identity. */
107export function grantFromForm(form: FormLike, options: { allowFull?: boolean } = {}): Parsed<GrantInput> {
108 const scopes = scopesFromForm(form, options);
109 if (!scopes.ok) return scopes;
110 return { ok: true, value: { scopes: scopes.value } };
111}
112
113/** What an application asked for in `scope`; nothing usable means the default set, never admin. */
114export function requestedScopes(scope: string | null | undefined): Scope[] {
115 const asked = parseScopes(scope ?? "");
116 return asked.length > 0 ? asked : [...OAUTH_DEFAULT_SCOPES];
117}
118
119/**
120 * The scopes kept on the consent page: only what the application asked
121 * for, whatever the form says. A box greyed out because a higher one of
122 * its resource is ticked is not posted, and needs not be: the higher one
123 * gives it.
124 */
125export function consentedScopes(form: FormLike, requested: readonly Scope[]): Scope[] {
126 const ticked = new Set(form.getAll("scope").map(String));
127 return normalizeScopes(requested.filter((scope) => ticked.has(scope)));
128}
129
130/** Expiry choices for a new token, in days; `never` does not expire. */
131export const EXPIRY_CHOICES = [
132 { value: "7", label: "7 days" },
133 { value: "30", label: "30 days" },
134 { value: "90", label: "90 days" },
135 { value: "365", label: "1 year" },
136 { value: "never", label: "No expiry" },
137] as const;
138
139export const DEFAULT_EXPIRY = "90";
140
141/** Seconds a new token lives, or undefined for no expiry. Anything unknown is the default. */
142export function expiryTtl(value: unknown): number | undefined {
143 const text = String(value ?? DEFAULT_EXPIRY);
144 if (text === "never") return undefined;
145 const days = EXPIRY_CHOICES.some((choice) => choice.value === text) ? Number(text) : Number(DEFAULT_EXPIRY);
146 return days * 86_400;
147}
148
149/** "Expires in 3 days", "Expired", "No expiry". */
150export function describeExpiry(expiresAt: string | null, now = Date.now()): string {
151 if (!expiresAt) return "No expiry";
152 const left = new Date(expiresAt).getTime() - now;
153 if (left <= 0) return "Expired";
154 const hours = left / 3_600_000;
155 if (hours < 1) return "Expires in under an hour";
156 const plural = (count: number, unit: string) => `Expires in ${count} ${unit}${count === 1 ? "" : "s"}`;
157 if (hours < 48) return plural(Math.round(hours), "hour");
158 const days = Math.round(hours / 24);
159 if (days < 60) return plural(days, "day");
160 const months = Math.round(days / 30);
161 if (months < 24) return plural(months, "month");
162 return plural(Math.round(days / 365), "year");
163}