Skip to content

Commit cef6b8e

Browse files
sametbasbugclaude
andcommitted
Add Orbit sign-in for readers
First half of making Orbit the identity layer for every Equinox site. Haber is a direct OIDC client rather than going through Supabase the way anime does -- anime is static and needs a server somewhere, haber already has a Worker and D1, and a middleman is one more dependency, one more secret and one more outage to inherit. The ID token verification is not rewritten: verifyOrbitToken in worker/src/identity.ts already does ES256 over JWKS from the discovery document, with iss/aud/exp and key rotation, and has 22 cases behind it. Those cases existed but ran from nowhere, so they now run in CI along with the new ones -- a suite nothing executes is documentation, not a test. The part that needed care was the edge cache. Its key is URL plus content version and does not vary on Cookie, so a personalized response entering it would be served to everyone -- one reader's name on another reader's screen. Rather than listing which pages are personal, which leaks the day the list falls behind, any request carrying a session cookie now bypasses the cache entirely and its response is marked private, no-store. Logged-in readers pay a D1 read per page; that is the right trade. Sessions are haber's own, not Orbit's token in a cookie: a short-lived token in a cookie would put Orbit in the path of every page render. Cookie carries <selector>.<secret>, the secret is stored only as a SHA-256 digest, and logout revokes rather than deletes. 21 new cases cover the rejection paths -- the happy path proves little here, since the failure mode is always accepting input that should have been refused. Confirmed the harness has teeth by dropping the `//` check from the return-path guard: the protocol-relative case failed as it should, then restored from a copy rather than git checkout. Nothing is live yet: with ORBIT_ISSUER, ORBIT_CLIENT_ID and ORBIT_CLIENT_SECRET unset the routes answer 503 and no sign-in button exists. 212 newsroom tests, 22 + 21 Orbit cases, 0 type errors, both builds complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 092ba5c commit cef6b8e

12 files changed

Lines changed: 886 additions & 2 deletions

File tree

‎.github/workflows/quality.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ jobs:
4444
- name: Install Node dependencies
4545
run: npm ci
4646

47+
# Orbit kimlik katmanı: ID token doğrulaması (gerçek ES256 kriptosuyla)
48+
# ve giriş akışının reddetme kararları. `orbit-token-tests.mjs` yazıldığı
49+
# günden beri VARDI ama hiçbir yerden koşulmuyordu — koşulmayan test,
50+
# test değil belgedir.
51+
- name: Orbit identity tests
52+
run: npm run test:orbit
53+
4754
# Tür kontrolü 2026-08-21'e kadar HİÇ koşmuyordu: tsconfig'deki
4855
# deprecated `baseUrl` TS 6'da hata verip tsc'yi daha başlamadan
4956
# düşürüyordu. Kaldırıldığında 14 gerçek hata çıktı; onikisi Worker

‎astro.config.ssr.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ function yayinUclari() {
2525
hooks: {
2626
/** @param {{ injectRoute: (route: { pattern: string, entrypoint: string }) => void }} ctx */
2727
'astro:config:setup': ({ injectRoute }) => {
28+
/* Orbit ile giriş. Üçü de yalnız sunucu modunda var: statik
29+
derlemede oturum diye bir şey yok. */
30+
injectRoute({ pattern: '/giris/orbit', entrypoint: './src/server/routes/giris.ts' });
31+
injectRoute({ pattern: '/giris/orbit/donus', entrypoint: './src/server/routes/giris-donus.ts' });
32+
injectRoute({ pattern: '/cikis', entrypoint: './src/server/routes/cikis.ts' });
33+
2834
injectRoute({ pattern: '/api/brief', entrypoint: './src/server/routes/brief.ts' });
2935
injectRoute({ pattern: '/api/publish', entrypoint: './src/server/routes/publish.ts' });
3036
injectRoute({ pattern: '/images/generated/[...path]', entrypoint: './src/server/routes/hero.ts' });

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"haber:ayna": "node scripts/haber-ayna.mjs",
3030
"haber:ayna:kuru": "node scripts/haber-ayna.mjs --kuru",
3131
"test:newsroom": "newsroom/.venv/bin/python -m pytest newsroom/tests",
32-
"quality": "npm run test:newsroom && npm run typecheck && npm run build"
32+
"test:orbit": "node worker/tools/orbit-token-tests.mjs && node worker/tools/orbit-giris-tests.mjs",
33+
"quality": "npm run test:newsroom && npm run test:orbit && npm run typecheck && npm run build"
3334
},
3435
"dependencies": {
3536
"@astrojs/markdown-remark": "^7.2.4",

‎src/middleware.ts‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MiddlewareHandler } from 'astro';
22
import { getDatabase } from '#runtime-env';
3+
import { oturumCereziVarMi } from './server/session';
34

45
/* Kenar önbelleği.
56
*
@@ -60,13 +61,17 @@ const BIRLESTIRILEN: Record<string, string> = {
6061
'google-mac-icin-gemini-uygulamasini-kullanima-acti',
6162
};
6263

64+
/* Giriş akışının adresleri. Önbelleğe hiç girmiyorlar. */
65+
const GIRIS_YOLLARI = ['/giris/', '/cikis'];
66+
6367
const TTL_SECONDS = 60;
6468
const STALE_SECONDS = 300;
6569

6670
export const onRequest: MiddlewareHandler = async (context, next) => {
6771
/* Yönlendirme önbellekten önce: kaldırılan adresin eski bir kopyası
6872
önbellekte kalmış olsa bile eşine gitmeli. */
69-
const yol = new URL(context.request.url).pathname.replace(/^\/+|\/+$/g, '');
73+
const yolAdi = new URL(context.request.url).pathname;
74+
const yol = yolAdi.replace(/^\/+|\/+$/g, '');
7075
const hedef = BIRLESTIRILEN[yol];
7176
if (hedef) return context.redirect(`/${hedef}/`, 301);
7277

@@ -77,6 +82,35 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
7782
cevabı dönerdi. */
7883
if (!cache || context.request.method !== 'GET') return next();
7984

85+
/* GİRİŞ YAPMIŞ İSTEK ÖNBELLEĞE HİÇ UĞRAMAZ.
86+
*
87+
* Önbellek anahtarı adres + içerik sürümünden ibaret; çereze göre
88+
* DEĞİŞMİYOR. Kişiye özel bir cevap bir kez oraya girse, aynı adresi
89+
* isteyen herkese o cevap dönerdi — bir kullanıcının adı başkasının
90+
* ekranında görünürdü.
91+
*
92+
* "Kişisel sayfaları listeleyip onları atla" diye çözmedim: liste eksik
93+
* kaldığı gün sızıntı sessizce başlar. Kural tersine kuruluyor — oturum
94+
* çerezi taşıyan HER istek önbelleği baştan sona atlıyor ve cevabı
95+
* hiçbir paylaşımlı önbelleğe koydurmuyor.
96+
*
97+
* Bedeli: giriş yapmış kullanıcı her sayfada D1'e gidiyor. Kabul
98+
* edilebilir — giriş yapmış kullanıcı azdır ve doğru cevap ucuz cevaptan
99+
* önce gelir. */
100+
if (oturumCereziVarMi(context.request)) {
101+
const kisisel = await next();
102+
kisisel.headers.set('cache-control', 'private, no-store');
103+
return kisisel;
104+
}
105+
106+
/* Giriş akışının adresleri: çerez henüz yokken de önbelleğe girmemeliler.
107+
`/giris/orbit/donus` tek kullanımlık bir kod taşıyor. */
108+
if (GIRIS_YOLLARI.some((yol) => yolAdi.startsWith(yol))) {
109+
const akis = await next();
110+
akis.headers.set('cache-control', 'private, no-store');
111+
return akis;
112+
}
113+
80114
const version = await contentVersion();
81115
const key = versionedKey(context.request, version);
82116

‎src/server/orbit.ts‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/* Orbit ile giriş — istemci tarafı.
2+
*
3+
* Haber, Orbit'in doğrudan OIDC istemcisi. Anime'de araya Supabase giriyor
4+
* (Orbit'teki kayıtlı istemci aslında Supabase; yönlendirme adresi
5+
* `…supabase.co/auth/v1/callback`), çünkü anime statik bir site ve sunucusu
6+
* yok. Haber'in kendi Worker'ı ve D1'i var, yani aracıya gerek yok — ve bir
7+
* aracı daha az bağımlılık, daha az sır, daha az kesinti kaynağı demek.
8+
*
9+
* Token'ın DOĞRULANMASI burada değil: `verifyOrbitToken` zaten yazılmış ve
10+
* sınanmış durumda (`worker/src/identity.ts`). Burada yalnız akışın
11+
* tarayıcı tarafı var.
12+
*/
13+
14+
const encoder = new TextEncoder();
15+
16+
export interface OrbitConfig {
17+
issuer: string;
18+
clientId: string;
19+
clientSecret: string;
20+
}
21+
22+
interface Discovery {
23+
authorization_endpoint: string;
24+
token_endpoint: string;
25+
}
26+
27+
/* Keşif belgesi önbelleği. Uç adresleri sabit yazmıyoruz — belge sözleşmenin
28+
* kendisi — ama her girişte çekmek de Orbit'in bir saniyelik kesintisini
29+
* haberin giriş kesintisi yapardı. */
30+
const DISCOVERY_TTL_MS = 10 * 60 * 1000;
31+
let discoveryCache: { value: Discovery; fetchedAt: number } | null = null;
32+
33+
export async function discover(issuer: string): Promise<Discovery> {
34+
if (discoveryCache && Date.now() - discoveryCache.fetchedAt < DISCOVERY_TTL_MS) {
35+
return discoveryCache.value;
36+
}
37+
const response = await fetch(`${issuer}/.well-known/openid-configuration`);
38+
if (!response.ok) throw new Error(`Orbit keşif belgesi alınamadı: ${response.status}`);
39+
const value = (await response.json()) as Discovery;
40+
if (!value.authorization_endpoint || !value.token_endpoint) {
41+
throw new Error('Orbit keşif belgesi eksik uç adresi taşıyor');
42+
}
43+
discoveryCache = { value, fetchedAt: Date.now() };
44+
return value;
45+
}
46+
47+
export function base64Url(bytes: Uint8Array): string {
48+
let binary = '';
49+
for (const byte of bytes) binary += String.fromCharCode(byte);
50+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
51+
}
52+
53+
export function randomToken(byteLength = 32): string {
54+
return base64Url(crypto.getRandomValues(new Uint8Array(byteLength)));
55+
}
56+
57+
export async function sha256Base64Url(value: string): Promise<string> {
58+
return base64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(value))));
59+
}
60+
61+
/** PKCE doğrulayıcısı ve ondan türeyen meydan okuma.
62+
*
63+
* Orbit yalnız `S256` ilan ediyor; düz `plain` yöntemi hiç istenmiyor.
64+
* Doğrulayıcı 43-128 karakter olmak zorunda (RFC 7636); 32 bayt base64url
65+
* tam 43 karakter veriyor. */
66+
export async function pkce(): Promise<{ verifier: string; challenge: string }> {
67+
const verifier = randomToken(32);
68+
return { verifier, challenge: await sha256Base64Url(verifier) };
69+
}
70+
71+
export async function authorizeUrl(options: {
72+
config: OrbitConfig;
73+
redirectUri: string;
74+
state: string;
75+
nonce: string;
76+
challenge: string;
77+
scopes: readonly string[];
78+
}): Promise<string> {
79+
const { authorization_endpoint } = await discover(options.config.issuer);
80+
const url = new URL(authorization_endpoint);
81+
url.searchParams.set('response_type', 'code');
82+
url.searchParams.set('client_id', options.config.clientId);
83+
url.searchParams.set('redirect_uri', options.redirectUri);
84+
url.searchParams.set('scope', options.scopes.join(' '));
85+
url.searchParams.set('state', options.state);
86+
url.searchParams.set('nonce', options.nonce);
87+
url.searchParams.set('code_challenge', options.challenge);
88+
url.searchParams.set('code_challenge_method', 'S256');
89+
return url.toString();
90+
}
91+
92+
export interface TokenResponse {
93+
id_token: string;
94+
access_token?: string;
95+
refresh_token?: string;
96+
expires_in?: number;
97+
}
98+
99+
export async function exchangeCode(options: {
100+
config: OrbitConfig;
101+
code: string;
102+
verifier: string;
103+
redirectUri: string;
104+
}): Promise<TokenResponse> {
105+
const { token_endpoint } = await discover(options.config.issuer);
106+
const body = new URLSearchParams({
107+
grant_type: 'authorization_code',
108+
code: options.code,
109+
code_verifier: options.verifier,
110+
redirect_uri: options.redirectUri,
111+
});
112+
113+
/* İstemci sırrı Basic başlığında, gövdede değil. İkisi de kabul ediliyor
114+
* (keşif belgesi ikisini de ilan ediyor) ama başlık yolu, sırrın bir gün
115+
* bir istek gövdesi günlüğüne düşme ihtimalini azaltıyor. */
116+
const basic = btoa(
117+
`${encodeURIComponent(options.config.clientId)}:${encodeURIComponent(options.config.clientSecret)}`,
118+
);
119+
120+
const response = await fetch(token_endpoint, {
121+
method: 'POST',
122+
headers: {
123+
'content-type': 'application/x-www-form-urlencoded',
124+
authorization: `Basic ${basic}`,
125+
},
126+
body,
127+
});
128+
129+
if (!response.ok) {
130+
/* Orbit'in hata gövdesi `error` ve `error_description` taşıyor. Kodu
131+
* yansıtıyoruz ama açıklamayı kullanıcıya göstermiyoruz: karşı tarafın
132+
* metnini kendi sayfamıza basmak, oraya yazılan her şeyi bize
133+
* yazdırmaktır. */
134+
const detay = await response.text().catch(() => '');
135+
throw new Error(`Orbit token takası reddetti (${response.status}): ${detay.slice(0, 200)}`);
136+
}
137+
138+
const tokens = (await response.json()) as TokenResponse;
139+
if (typeof tokens.id_token !== 'string' || tokens.id_token.length === 0) {
140+
throw new Error('Orbit yanıtı id_token taşımıyor');
141+
}
142+
return tokens;
143+
}
144+
145+
/** ID token'ın gövdesini okur — DOĞRULAMADAN.
146+
*
147+
* İmza kontrolü `verifyOrbitToken`ın işi ve bu fonksiyon onun yerine
148+
* geçmez. Buradan okunan tek şey imzanın doğrulandığı BİLİNDİKTEN sonra
149+
* gereken ek alanlar: `nonce`, `name`, `picture`. Sıra bozulursa doğrulanmamış
150+
* bir gövdeye güvenilmiş olur, o yüzden çağıran taraf önce doğrulamalı.
151+
*/
152+
export function claimsOku(idToken: string): Record<string, unknown> | null {
153+
const parcalar = idToken.split('.');
154+
if (parcalar.length !== 3) return null;
155+
try {
156+
const base64 = parcalar[1].replaceAll('-', '+').replaceAll('_', '/');
157+
const dolgulu = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
158+
const ikili = atob(dolgulu);
159+
const baytlar = Uint8Array.from(ikili, (c) => c.charCodeAt(0));
160+
const cozulmus = JSON.parse(new TextDecoder().decode(baytlar));
161+
return cozulmus && typeof cozulmus === 'object' ? cozulmus : null;
162+
} catch {
163+
return null;
164+
}
165+
}

‎src/server/routes/cikis.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* Oturumu kapatır.
2+
*
3+
* POST, çünkü çıkış da bir yan etki: GET olsaydı bir `<img src="/cikis">`
4+
* kullanıcıyı sessizce çıkarabilirdi.
5+
*
6+
* Yalnız BU sitedeki oturumu kapatıyor, Orbit oturumunu değil. Kullanıcı
7+
* "çıkış" derken bu siteden çıkmayı kastediyor; Orbit'ten de atmak, aynı
8+
* tarayıcıdaki diğer Equinox sitelerini de kapatmak olurdu.
9+
*/
10+
import type { APIRoute } from 'astro';
11+
import { getDatabase } from '#runtime-env';
12+
import { oturumKapat } from '../session';
13+
14+
export const prerender = false;
15+
16+
export const POST: APIRoute = async ({ request }) => {
17+
const db = getDatabase();
18+
const headers = new Headers({ location: '/', 'cache-control': 'no-store' });
19+
if (db) headers.set('set-cookie', await oturumKapat(db, request, Date.now()));
20+
return new Response(null, { status: 303, headers });
21+
};

0 commit comments

Comments
 (0)