@terrafirmagreg-team/auth 0.0.0-stage → 0.1.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/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/lib/index.ts",
7
+ "./*": [
8
+ "./src/*.ts",
9
+ "./src/*.tsx",
10
+ "./src/*/index.ts",
11
+ "./src/*/index.tsx"
12
+ ]
13
+ },
14
+ "files": [
15
+ "src",
16
+ "styles",
17
+ "styles.css",
18
+ "locales"
19
+ ],
20
+ "dependencies": {
21
+ "@terrafirmagreg-team/data": "0.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^26.6.5"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
29
+ "directory": "packages/auth"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
6
34
  }
@@ -0,0 +1,69 @@
1
+ import {
2
+ githubFetch,
3
+ parseCookies,
4
+ readTokenFromCookieHeader,
5
+ } from '@terrafirmagreg-team/data';
6
+
7
+ export type Awaitable<T> = T | Promise<T>;
8
+
9
+ export interface CookieRequest {
10
+ headers: {
11
+ get(key: string): string | readonly string[] | null | undefined;
12
+ };
13
+ }
14
+
15
+ export interface CookieSession {
16
+ id: string;
17
+ }
18
+
19
+ export interface SessionAdapter<R extends CookieRequest = CookieRequest> {
20
+ getSession(request: R): Awaitable<CookieSession | null>;
21
+ }
22
+
23
+ const headerValue = (value: string | readonly string[] | null | undefined) =>
24
+ Array.isArray(value) ? value[0] : (value ?? undefined);
25
+
26
+ export function githubCookieAuth({
27
+ cookieName,
28
+ tokenSecret,
29
+ }: {
30
+ cookieName: string;
31
+ tokenSecret: string;
32
+ }): SessionAdapter {
33
+ const cache = new Map<string, Promise<CookieSession | null>>();
34
+ return {
35
+ getSession(request) {
36
+ const token = readTokenFromCookieHeader(
37
+ headerValue(request.headers.get('cookie')),
38
+ cookieName,
39
+ tokenSecret,
40
+ );
41
+ if (!token) return null;
42
+ let session = cache.get(token);
43
+ if (!session) {
44
+ session = githubFetch('/user', { token })
45
+ .then(async (res) =>
46
+ res.ok ? { id: ((await res.json()) as { login: string }).login } : null,
47
+ )
48
+ .catch(() => null);
49
+ cache.set(token, session);
50
+ }
51
+ return session;
52
+ },
53
+ };
54
+ }
55
+
56
+ export function devCookieAuth({
57
+ cookieName,
58
+ }: {
59
+ cookieName: string;
60
+ }): SessionAdapter {
61
+ return {
62
+ getSession(request) {
63
+ const name = parseCookies(
64
+ headerValue(request.headers.get('cookie')),
65
+ )[cookieName]?.trim();
66
+ return name ? { id: name } : null;
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,51 @@
1
+ import { env } from '@terrafirmagreg-team/data';
2
+
3
+ export type AuthMode = 'dev' | 'github';
4
+ export const authCookie = 'fcgd_token';
5
+ export const devAuthCookie = 'fc_dev_user';
6
+
7
+ export function authMode(): AuthMode {
8
+ const mode = env('COMMENTS_AUTH');
9
+ if (mode) return mode === 'dev' ? 'dev' : 'github';
10
+ return process.env.NODE_ENV === 'development' ? 'dev' : 'github';
11
+ }
12
+
13
+ export function authEnabled() {
14
+ if (authMode() === 'dev') return true;
15
+ return Boolean(
16
+ env('COMMENTS_TOKEN_SECRET') && env('GITHUB_COMMENTS_CLIENT_ID'),
17
+ );
18
+ }
19
+
20
+ export function authOwnerLogins() {
21
+ return (env('GITHUB_COMMENTS_OWNER_LOGINS') ?? '')
22
+ .split(',')
23
+ .map((login) => login.trim())
24
+ .filter(Boolean);
25
+ }
26
+
27
+ export interface AuthConfig {
28
+ clientId: string;
29
+ clientSecret: string;
30
+ tokenSecret: string;
31
+ cookieName: string;
32
+ devCookieName: string;
33
+ callbackPath: string;
34
+ loginPath: string;
35
+ scope: string;
36
+ mode: AuthMode;
37
+ }
38
+
39
+ export function authConfig(): AuthConfig {
40
+ return {
41
+ clientId: env('GITHUB_COMMENTS_CLIENT_ID')!,
42
+ clientSecret: env('GITHUB_COMMENTS_CLIENT_SECRET')!,
43
+ tokenSecret: env('COMMENTS_TOKEN_SECRET')!,
44
+ cookieName: authCookie,
45
+ devCookieName: devAuthCookie,
46
+ callbackPath: '/api/comments/oauth/callback',
47
+ loginPath: '/api/comments/oauth/login',
48
+ scope: 'public_repo',
49
+ mode: authMode(),
50
+ };
51
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ authConfig,
3
+ authEnabled,
4
+ authMode,
5
+ authOwnerLogins,
6
+ authCookie,
7
+ devAuthCookie,
8
+ type AuthConfig,
9
+ type AuthMode,
10
+ } from './config';
11
+ export { createOAuthFlow, type OAuthFlow } from './oauth';
12
+ export {
13
+ devCookieAuth,
14
+ githubCookieAuth,
15
+ type CookieRequest,
16
+ type CookieSession,
17
+ type SessionAdapter,
18
+ } from './adapters';
19
+
20
+ import { createOAuthFlow } from './oauth';
21
+ import { authConfig } from './config';
22
+
23
+ const flow = createOAuthFlow(authConfig);
24
+
25
+ export const { login, callback, logout, devLogin } = flow;
@@ -0,0 +1,182 @@
1
+ import { decrypt, encrypt } from '@terrafirmagreg-team/data';
2
+ import type { AuthConfig } from './config';
3
+
4
+ const GITHUB_AUTHORIZE = 'https://github.com/login/oauth/authorize';
5
+ const GITHUB_ACCESS_TOKEN = 'https://github.com/login/oauth/access_token';
6
+ const STATE_TTL = 10 * 60 * 1000;
7
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
8
+ const COOKIE_BASE = 'Path=/; Max-Age=%d; SameSite=Lax; HttpOnly; Secure';
9
+ const DEV_NAME_MAX = 40;
10
+
11
+ export interface OAuthFlow {
12
+ login(request: Request): Response;
13
+ devLogin(request: Request): Promise<Response>;
14
+ callback(request: Request): Promise<Response>;
15
+ logout(request: Request): Response;
16
+ }
17
+
18
+ function sameOrigin(candidate: string | null, self: string): string {
19
+ if (!candidate) return `${self}/`;
20
+ try {
21
+ const parsed = new URL(candidate, self);
22
+ return parsed.origin === self ? parsed.href : `${self}/`;
23
+ } catch {
24
+ return `${self}/`;
25
+ }
26
+ }
27
+
28
+ function setCookieHeader(name: string, value: string, maxAge: number) {
29
+ return `${name}=${encodeURIComponent(value)}; ${COOKIE_BASE.replace('%d', String(maxAge))}`;
30
+ }
31
+
32
+ function encodeState(returnUrl: string, secret: string) {
33
+ return encrypt(
34
+ JSON.stringify({ r: returnUrl, e: Date.now() + STATE_TTL }),
35
+ secret,
36
+ );
37
+ }
38
+
39
+ function decodeState(state: string, secret: string): string {
40
+ const payload = JSON.parse(decrypt(state, secret)) as { r?: unknown; e?: unknown };
41
+ if (typeof payload.e !== 'number' || Date.now() > payload.e)
42
+ throw new Error('expired');
43
+ if (typeof payload.r !== 'string') throw new Error('invalid');
44
+ return payload.r;
45
+ }
46
+
47
+ function devName(value: unknown): string | null {
48
+ if (typeof value !== 'string') return null;
49
+ const name = value
50
+ .replace(/[^\p{L}\p{N} _.\-]/gu, '')
51
+ .trim()
52
+ .slice(0, DEV_NAME_MAX);
53
+ return name || null;
54
+ }
55
+
56
+ function devLoginForm(request: Request, config: AuthConfig): Response {
57
+ const self = new URL(request.url).origin;
58
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
59
+ const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in</title><style>
60
+ :root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}
61
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:light-dark(#fafafa,#0a0a0a);color:light-dark(#171717,#ededed)}
62
+ form{display:flex;flex-direction:column;gap:12px;min-width:280px;padding:28px;border:1px solid light-dark(#e5e5e5,#262626);border-radius:16px;background:light-dark(#fff,#141414);box-shadow:0 1px 2px rgb(0 0 0/.05)}
63
+ h1{font-size:20px;font-weight:600;margin:0 0 4px}
64
+ input,button{font:inherit;padding:8px 12px;border-radius:8px;border:1px solid light-dark(#e5e5e5,#262626);background:transparent;color:inherit}
65
+ input:focus-visible{outline:2px solid light-dark(#171717,#ededed);outline-offset:2px}
66
+ button{background:light-dark(#171717,#ededed);color:light-dark(#fafafa,#0a0a0a);border:0;font-weight:500;cursor:pointer}
67
+ button:hover{opacity:.85}
68
+ </style></head><body>
69
+ <form method="post" action="${config.loginPath}">
70
+ <h1>Sign in to comment</h1>
71
+ <input name="name" required maxlength="${DEV_NAME_MAX}" placeholder="Display name" autocomplete="username" />
72
+ <input type="hidden" name="return" value="${returnUrl.replaceAll('"', '&quot;')}" />
73
+ <button type="submit">Sign in</button>
74
+ </form></body></html>`;
75
+ return new Response(html, {
76
+ headers: { 'content-type': 'text/html; charset=utf-8' },
77
+ });
78
+ }
79
+
80
+ export function createOAuthFlow(getConfig: () => AuthConfig): OAuthFlow {
81
+ function login(request: Request): Response {
82
+ const config = getConfig();
83
+ if (config.mode === 'dev') return devLoginForm(request, config);
84
+ const self = new URL(request.url).origin;
85
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
86
+ const params = new URLSearchParams({
87
+ client_id: config.clientId,
88
+ redirect_uri: `${self}${config.callbackPath}`,
89
+ scope: config.scope,
90
+ state: encodeState(returnUrl, config.tokenSecret),
91
+ });
92
+ return Response.redirect(`${GITHUB_AUTHORIZE}?${params}`, 302);
93
+ }
94
+
95
+ async function devLogin(request: Request): Promise<Response> {
96
+ const config = getConfig();
97
+ if (config.mode !== 'dev')
98
+ return Response.json({ message: 'Dev auth disabled' }, { status: 404 });
99
+ const form = await request.formData();
100
+ const name = devName(form.get('name'));
101
+ const self = new URL(request.url).origin;
102
+ const returnUrl = sameOrigin(
103
+ typeof form.get('return') === 'string' ? (form.get('return') as string) : null,
104
+ self,
105
+ );
106
+ if (!name)
107
+ return Response.json({ message: 'Invalid name' }, { status: 400 });
108
+ return new Response(null, {
109
+ status: 302,
110
+ headers: {
111
+ location: returnUrl,
112
+ 'set-cookie': setCookieHeader(config.devCookieName, name, COOKIE_MAX_AGE),
113
+ },
114
+ });
115
+ }
116
+
117
+ async function callback(request: Request): Promise<Response> {
118
+ const config = getConfig();
119
+ const url = new URL(request.url);
120
+ const self = url.origin;
121
+ let returnUrl = `${self}/`;
122
+ const state = url.searchParams.get('state');
123
+ if (state) {
124
+ try {
125
+ returnUrl = decodeState(state, config.tokenSecret);
126
+ } catch {
127
+ return Response.json(
128
+ { message: 'Invalid or expired sign-in state' },
129
+ { status: 400 },
130
+ );
131
+ }
132
+ }
133
+ const code = url.searchParams.get('code');
134
+ if (url.searchParams.get('error') || !code)
135
+ return Response.redirect(returnUrl, 302);
136
+ try {
137
+ const res = await fetch(GITHUB_ACCESS_TOKEN, {
138
+ method: 'POST',
139
+ headers: {
140
+ accept: 'application/json',
141
+ 'content-type': 'application/x-www-form-urlencoded',
142
+ },
143
+ body: new URLSearchParams({
144
+ client_id: config.clientId,
145
+ client_secret: config.clientSecret,
146
+ code,
147
+ }),
148
+ });
149
+ const { access_token } = (await res.json()) as { access_token?: string };
150
+ if (!access_token) throw new Error('no token');
151
+ return new Response(null, {
152
+ status: 302,
153
+ headers: {
154
+ location: returnUrl,
155
+ 'set-cookie': setCookieHeader(
156
+ config.cookieName,
157
+ encrypt(access_token, config.tokenSecret),
158
+ COOKIE_MAX_AGE,
159
+ ),
160
+ },
161
+ });
162
+ } catch {
163
+ return Response.json({ message: 'GitHub sign-in failed' }, { status: 502 });
164
+ }
165
+ }
166
+
167
+ function logout(request: Request): Response {
168
+ const config = getConfig();
169
+ const self = new URL(request.url).origin;
170
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
171
+ return new Response(null, {
172
+ status: 302,
173
+ headers: [
174
+ ['location', returnUrl],
175
+ ['set-cookie', setCookieHeader(config.cookieName, '', 0)],
176
+ ['set-cookie', setCookieHeader(config.devCookieName, '', 0)],
177
+ ],
178
+ });
179
+ }
180
+
181
+ return { login, devLogin, callback, logout };
182
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.