@terrafirmagreg-team/auth 0.0.0-stage → 1.2.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,32 @@
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": "1.2.0",
4
+ "type": "module",
5
+ "description": "GitHub OAuth sign-in and cookie sessions",
6
+ "license": "LGPL-3.0-only",
7
+ "exports": {
8
+ ".": "./src/lib/index.ts",
9
+ "./lib/*": [
10
+ "./src/lib/*.ts",
11
+ "./src/lib/*/index.ts"
12
+ ]
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "dependencies": {
18
+ "@terrafirmagreg-team/data": "1.2.0",
19
+ "arctic": "^3.7.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^26.6.5"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
27
+ "directory": "packages/auth"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
6
32
  }
@@ -0,0 +1,73 @@
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
+ session.then((s) => {
51
+ if (!s && cache.get(token) === session) cache.delete(token);
52
+ });
53
+ }
54
+ return session;
55
+ },
56
+ };
57
+ }
58
+
59
+ export function devCookieAuth({
60
+ cookieName,
61
+ }: {
62
+ cookieName: string;
63
+ }): SessionAdapter {
64
+ return {
65
+ getSession(request) {
66
+ const name = parseCookies(
67
+ headerValue(request.headers.get('cookie')),
68
+ )[cookieName]?.trim();
69
+ return name ? { id: name } : null;
70
+ },
71
+ };
72
+ }
73
+
@@ -0,0 +1,43 @@
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(env('DISCUSSIONS_SECRET') && env('APP_CLIENT_ID'));
16
+ }
17
+
18
+ export interface AuthConfig {
19
+ clientId: string;
20
+ clientSecret: string;
21
+ tokenSecret: string;
22
+ cookieName: string;
23
+ devCookieName: string;
24
+ callbackPath: string;
25
+ loginPath: string;
26
+ scope: string;
27
+ mode: AuthMode;
28
+ }
29
+
30
+ export function authConfig(): AuthConfig {
31
+ return {
32
+ clientId: env('APP_CLIENT_ID')!,
33
+ clientSecret: env('APP_SECRET')!,
34
+ tokenSecret: env('DISCUSSIONS_SECRET') ?? 'dev-token-secret',
35
+ cookieName: authCookie,
36
+ devCookieName: devAuthCookie,
37
+ callbackPath: '/api/comments/oauth/callback',
38
+ loginPath: '/api/comments/oauth/login',
39
+ scope: '',
40
+ mode: authMode(),
41
+ };
42
+ }
43
+
@@ -0,0 +1,25 @@
1
+ export {
2
+ authConfig,
3
+ authEnabled,
4
+ authMode,
5
+ authCookie,
6
+ devAuthCookie,
7
+ type AuthConfig,
8
+ type AuthMode,
9
+ } from './config';
10
+ export { createOAuthFlow, type OAuthFlow } from './oauth';
11
+ export {
12
+ devCookieAuth,
13
+ githubCookieAuth,
14
+ type CookieRequest,
15
+ type CookieSession,
16
+ type SessionAdapter,
17
+ } from './adapters';
18
+
19
+ import { createOAuthFlow } from './oauth';
20
+ import { authConfig } from './config';
21
+
22
+ const flow = createOAuthFlow(authConfig);
23
+
24
+ export const { login, callback, logout, devLogin } = flow;
25
+
@@ -0,0 +1,175 @@
1
+ import { decrypt, encrypt } from '@terrafirmagreg-team/data';
2
+ import { GitHub } from 'arctic';
3
+ import type { AuthConfig } from './config';
4
+
5
+ const STATE_TTL = 10 * 60 * 1000;
6
+ const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
7
+ const COOKIE_BASE = 'Path=/; Max-Age=%d; SameSite=Lax; HttpOnly; Secure';
8
+ const DEV_NAME_MAX = 40;
9
+
10
+ export interface OAuthFlow {
11
+ login(request: Request): Response;
12
+ devLogin(request: Request): Promise<Response>;
13
+ callback(request: Request): Promise<Response>;
14
+ logout(request: Request): Response;
15
+ }
16
+
17
+ function sameOrigin(candidate: string | null, self: string): string {
18
+ if (!candidate) return `${self}/`;
19
+ try {
20
+ const parsed = new URL(candidate, self);
21
+ return parsed.origin === self ? parsed.href : `${self}/`;
22
+ } catch {
23
+ return `${self}/`;
24
+ }
25
+ }
26
+
27
+ function setCookieHeader(name: string, value: string, maxAge: number) {
28
+ return `${name}=${encodeURIComponent(value)}; ${COOKIE_BASE.replace('%d', String(maxAge))}`;
29
+ }
30
+
31
+ function encodeState(returnUrl: string, secret: string) {
32
+ return encrypt(
33
+ JSON.stringify({ r: returnUrl, e: Date.now() + STATE_TTL }),
34
+ secret,
35
+ );
36
+ }
37
+
38
+ function decodeState(state: string, secret: string): string {
39
+ const payload = JSON.parse(decrypt(state, secret)) as { r?: unknown; e?: unknown };
40
+ if (typeof payload.e !== 'number' || Date.now() > payload.e)
41
+ throw new Error('expired');
42
+ if (typeof payload.r !== 'string') throw new Error('invalid');
43
+ return payload.r;
44
+ }
45
+
46
+ function devName(value: unknown): string | null {
47
+ if (typeof value !== 'string') return null;
48
+ const name = value
49
+ .replace(/[^\p{L}\p{N} _.\-]/gu, '')
50
+ .trim()
51
+ .slice(0, DEV_NAME_MAX);
52
+ return name || null;
53
+ }
54
+
55
+ function devLoginForm(request: Request, config: AuthConfig): Response {
56
+ const self = new URL(request.url).origin;
57
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
58
+ 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>
59
+ :root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,sans-serif}
60
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:light-dark(#fafafa,#0a0a0a);color:light-dark(#171717,#ededed)}
61
+ 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)}
62
+ h1{font-size:20px;font-weight:600;margin:0 0 4px}
63
+ input,button{font:inherit;padding:8px 12px;border-radius:8px;border:1px solid light-dark(#e5e5e5,#262626);background:transparent;color:inherit}
64
+ input:focus-visible{outline:2px solid light-dark(#171717,#ededed);outline-offset:2px}
65
+ button{background:light-dark(#171717,#ededed);color:light-dark(#fafafa,#0a0a0a);border:0;font-weight:500;cursor:pointer}
66
+ button:hover{opacity:.85}
67
+ </style></head><body>
68
+ <form method="post" action="${config.loginPath}">
69
+ <h1>Sign in to comment</h1>
70
+ <input name="name" required maxlength="${DEV_NAME_MAX}" placeholder="Display name" autocomplete="username" />
71
+ <input type="hidden" name="return" value="${returnUrl.replaceAll('"', '&quot;')}" />
72
+ <button type="submit">Sign in</button>
73
+ </form></body></html>`;
74
+ return new Response(html, {
75
+ headers: { 'content-type': 'text/html; charset=utf-8' },
76
+ });
77
+ }
78
+
79
+ export function createOAuthFlow(getConfig: () => AuthConfig): OAuthFlow {
80
+ function github(config: AuthConfig, self: string): GitHub {
81
+ return new GitHub(
82
+ config.clientId,
83
+ config.clientSecret,
84
+ `${self}${config.callbackPath}`,
85
+ );
86
+ }
87
+
88
+ function login(request: Request): Response {
89
+ const config = getConfig();
90
+ if (config.mode === 'dev') return devLoginForm(request, config);
91
+ const self = new URL(request.url).origin;
92
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
93
+ const url = github(config, self).createAuthorizationURL(
94
+ encodeState(returnUrl, config.tokenSecret),
95
+ config.scope.split(' ').filter(Boolean),
96
+ );
97
+ return Response.redirect(url, 302);
98
+ }
99
+
100
+ async function devLogin(request: Request): Promise<Response> {
101
+ const config = getConfig();
102
+ if (config.mode !== 'dev')
103
+ return Response.json({ message: 'Dev auth disabled' }, { status: 404 });
104
+ const form = await request.formData();
105
+ const name = devName(form.get('name'));
106
+ const self = new URL(request.url).origin;
107
+ const returnUrl = sameOrigin(
108
+ typeof form.get('return') === 'string' ? (form.get('return') as string) : null,
109
+ self,
110
+ );
111
+ if (!name)
112
+ return Response.json({ message: 'Invalid name' }, { status: 400 });
113
+ return new Response(null, {
114
+ status: 302,
115
+ headers: {
116
+ location: returnUrl,
117
+ 'set-cookie': setCookieHeader(config.devCookieName, name, COOKIE_MAX_AGE),
118
+ },
119
+ });
120
+ }
121
+
122
+ async function callback(request: Request): Promise<Response> {
123
+ const config = getConfig();
124
+ const url = new URL(request.url);
125
+ const self = url.origin;
126
+ let returnUrl = `${self}/`;
127
+ const state = url.searchParams.get('state');
128
+ if (state) {
129
+ try {
130
+ returnUrl = decodeState(state, config.tokenSecret);
131
+ } catch {
132
+ return Response.json(
133
+ { message: 'Invalid or expired sign-in state' },
134
+ { status: 400 },
135
+ );
136
+ }
137
+ }
138
+ const code = url.searchParams.get('code');
139
+ if (url.searchParams.get('error') || !code)
140
+ return Response.redirect(returnUrl, 302);
141
+ try {
142
+ const tokens = await github(config, self).validateAuthorizationCode(code);
143
+ return new Response(null, {
144
+ status: 302,
145
+ headers: {
146
+ location: returnUrl,
147
+ 'set-cookie': setCookieHeader(
148
+ config.cookieName,
149
+ encrypt(tokens.accessToken(), config.tokenSecret),
150
+ COOKIE_MAX_AGE,
151
+ ),
152
+ },
153
+ });
154
+ } catch {
155
+ return Response.json({ message: 'GitHub sign-in failed' }, { status: 502 });
156
+ }
157
+ }
158
+
159
+ function logout(request: Request): Response {
160
+ const config = getConfig();
161
+ const self = new URL(request.url).origin;
162
+ const returnUrl = sameOrigin(new URL(request.url).searchParams.get('return'), self);
163
+ return new Response(null, {
164
+ status: 302,
165
+ headers: [
166
+ ['location', returnUrl],
167
+ ['set-cookie', setCookieHeader(config.cookieName, '', 0)],
168
+ ['set-cookie', setCookieHeader(config.devCookieName, '', 0)],
169
+ ],
170
+ });
171
+ }
172
+
173
+ return { login, devLogin, callback, logout };
174
+ }
175
+
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.