Skip to content

Commit 83faba6

Browse files
sametbasbugclaude
andcommitted
Add a repeatable way to register a site client
0041 closed dynamic registration on purpose -- a self-service client surface means an arbitrary name can appear on the consent screen. What it did not leave behind was any way to do it deliberately: the only code that inserts into oauth_clients is a test worker, and the one live row (orbit-equinox-rota) was entered by hand with no record of how. Every Equinox site is meant to sign in through Orbit, so this step has to be repeatable before it is repeated. The script prints SQL rather than writing to the database, because writing would need production credentials and because an operator should read the statement before applying it. Both the pepper and the client secret arrive on stdin, never as arguments -- arguments show up in ps output and shell history -- and neither is echoed; the output carries only the digest. The digest is the part that had to be right. It is checked against Orbit's own hmacDigest rather than a reimplementation: a drift there writes a perfectly valid row whose client can never authenticate, which is a miserable thing to diagnose in production. Also covered: the domain prefix actually being applied, canonical scope ordering, openid being required, fragments and plain-http redirects being rejected, and localhost being allowed only for development clients. Verified beyond the unit tests -- generated SQL for a development client, applied it to the local D1, and the row and its redirect URI landed. Then tried a production client with a localhost redirect, bypassing the script's own validation, and 0041's trigger rejected it with oauth_redirect_uri_insecure. Local D1 left empty afterwards. scripts/site-clients/haber.json is the first declaration: scopes and redirect URIs become a reviewable file instead of living only in a row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ab62705 commit 83faba6

4 files changed

Lines changed: 348 additions & 2 deletions

File tree

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
"staging:slice5:images-cpu": "tsx scripts/orbit-slice5-images-cpu-staging-e2e.ts",
3737
"orbit:import:verify": "tsx scripts/orbit-slice3-manifest.ts",
3838
"orbit:import:local": "tsx scripts/orbit-slice3-import.ts --local --database=orbit-v6-local --config=wrangler.test.jsonc",
39-
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
40-
"test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts",
39+
"test:d1": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-client-register-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts scripts/orbit-slice4-tests.ts scripts/orbit-slice5-tests.ts",
40+
"test:d1:core": "tsx --test --test-concurrency=5 scripts/orbit-test-ports-tests.ts scripts/orbit-site-authorization-tests.ts scripts/orbit-site-client-register-tests.ts scripts/orbit-site-signin-e2e-tests.ts scripts/orbit-mcp-authorization-tests.ts scripts/orbit-agent-identity-tests.ts scripts/orbit-api-contract-tests.ts scripts/orbit-handle-policy-tests.ts scripts/orbit-d1-tests.ts scripts/orbit-email-tests.ts scripts/orbit-public-page-tests.ts scripts/orbit-public-repository-tests.ts scripts/orbit-slice1-tests.ts scripts/orbit-slice1-worker-tests.ts scripts/orbit-slice3-tests.ts",
4141
"test:d1:publication": "tsx --test scripts/orbit-slice4-tests.ts scripts/orbit-single-renderer-tests.ts scripts/orbit-style-scale-tests.ts",
4242
"test:d1:platform": "tsx --test scripts/orbit-slice5-tests.ts",
4343
"v6:check": "npm run test:d1 && npm run check && npm run worker:build",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* `orbit-site-client-register.mjs` sınamaları.
2+
*
3+
* En önemlisi ilk testtir: özet, Orbit'in KENDİ `hmacDigest`iyle
4+
* karşılaştırılıyor, yeniden yazılmış bir referansla değil. Betikteki
5+
* hesap Orbit'ten ayrılırsa satır sorunsuz yazılır ama istemci kimlik
6+
* doğrulaması sessizce reddedilir — üretimde teşhisi zor bir hata.
7+
*/
8+
9+
import { strict as assert } from 'node:assert';
10+
import { test } from 'node:test';
11+
12+
import { hmacDigest } from '../src/server/identity/tokens';
13+
import {
14+
ayariDogrula,
15+
siteClientSecretDigest,
16+
sqlUret,
17+
} from './orbit-site-client-register.mjs';
18+
19+
const GECERLI = {
20+
clientId: 'orbit-haber',
21+
label: 'Equinox Haber',
22+
siteUrl: 'https://haber.sametbasbug.dev',
23+
scopes: ['openid', 'profile'],
24+
redirectUris: ['https://haber.sametbasbug.dev/giris/orbit/donus'],
25+
environment: 'production',
26+
};
27+
28+
test('özet Orbit\'in kendi hmacDigest\'iyle birebir aynı', async () => {
29+
for (const [sir, pepper] of [
30+
['istemci-sirri-abcdefghijklmnopqrstuvwxyz', 'peper-1'],
31+
['ünïcode-içeren-sır-ĞŞİıöç', 'peper-türkçe-ĞŞİ'],
32+
/* Boş sır bilerek var: özetin kendisi boş girdide de tanımlı olmalı.
33+
* Boş PEPER yok — WebCrypto sıfır uzunluklu HMAC anahtarını reddediyor
34+
* ve betik zaten boş peper'la çalışmayı en baştan kesiyor. */
35+
['', 'peper'],
36+
]) {
37+
const bizim = await siteClientSecretDigest(sir, pepper);
38+
// api.ts:4153'teki çağrının aynısı.
39+
const orbitin = await hmacDigest(`orbit:site-client-secret:v1:${sir}`, pepper);
40+
assert.equal(bizim, orbitin, `ayrışma: ${sir}`);
41+
}
42+
});
43+
44+
test('özet base64url, dolgusuz ve 43 karakter — canlı satırla aynı biçim', async () => {
45+
const ozet = await siteClientSecretDigest('a'.repeat(48), 'peper');
46+
assert.equal(ozet.length, 43);
47+
assert.match(ozet, /^[A-Za-z0-9_-]+$/u);
48+
});
49+
50+
test('alan öneki gerçekten uygulanıyor', async () => {
51+
const onekli = await siteClientSecretDigest('sir', 'peper');
52+
const oneksiz = await hmacDigest('sir', 'peper');
53+
assert.notEqual(onekli, oneksiz);
54+
});
55+
56+
test('kapsamlar kanonik sıraya diziliyor', () => {
57+
const ayar = ayariDogrula({ ...GECERLI, scopes: ['profile', 'openid'] });
58+
assert.deepEqual(ayar.scopes, ['openid', 'profile']);
59+
});
60+
61+
test('openid olmadan reddediliyor', () => {
62+
assert.throws(() => ayariDogrula({ ...GECERLI, scopes: ['profile'] }), /openid/u);
63+
});
64+
65+
test('tanınmayan kapsam reddediliyor', () => {
66+
assert.throws(
67+
() => ayariDogrula({ ...GECERLI, scopes: ['openid', 'orbit.dm.read'] }),
68+
/tanınmayan kapsam/u,
69+
);
70+
});
71+
72+
test('parça taşıyan yönlendirme adresi reddediliyor', () => {
73+
assert.throws(
74+
() => ayariDogrula({ ...GECERLI, redirectUris: ['https://x.dev/geri#kod'] }),
75+
/parça/u,
76+
);
77+
});
78+
79+
test('production ortamında localhost reddediliyor', () => {
80+
assert.throws(
81+
() => ayariDogrula({ ...GECERLI, redirectUris: ['http://localhost:4321/geri'] }),
82+
/https:\/\/ olmalı/u,
83+
);
84+
});
85+
86+
test('development ortamında localhost kabul ediliyor', () => {
87+
const ayar = ayariDogrula({
88+
...GECERLI,
89+
environment: 'development',
90+
siteUrl: 'http://localhost:4321',
91+
redirectUris: ['http://localhost:4321/giris/orbit/donus'],
92+
});
93+
assert.equal(ayar.redirectUris.length, 1);
94+
});
95+
96+
test('düz http uzak adres reddediliyor', () => {
97+
assert.throws(
98+
() => ayariDogrula({ ...GECERLI, redirectUris: ['http://haber.sametbasbug.dev/geri'] }),
99+
/https:\/\/ olmalı/u,
100+
);
101+
});
102+
103+
test('SQL tek tırnağı kaçırıyor', () => {
104+
const ayar = ayariDogrula({ ...GECERLI, label: "Samet'in Haberi" });
105+
const sql = sqlUret({
106+
ayar, ozet: 'OZET', satirId: 'ID', yonlendirmeIdleri: ['R1'], simdi: 1,
107+
});
108+
assert.match(sql, /'Samet''in Haberi'/u);
109+
});
110+
111+
test('SQL tek işlemde ve her yönlendirme adresi için satır üretiyor', () => {
112+
const ayar = ayariDogrula({
113+
...GECERLI,
114+
redirectUris: ['https://haber.sametbasbug.dev/a', 'https://haber.sametbasbug.dev/b'],
115+
});
116+
const sql = sqlUret({
117+
ayar, ozet: 'OZET', satirId: 'ID', yonlendirmeIdleri: ['R1', 'R2'], simdi: 7,
118+
});
119+
assert.match(sql, /^BEGIN TRANSACTION;/u);
120+
assert.match(sql, /COMMIT;$/u);
121+
assert.equal(sql.match(/INSERT INTO oauth_client_redirect_uris/gu)?.length, 2);
122+
assert.match(sql, /'openid profile'/u);
123+
// hash_version sabiti SQL'e gerçekten giriyor mu.
124+
assert.match(sql, /'OZET', 1,/u);
125+
});
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env node
2+
/* Orbit'e yeni bir alt site istemcisi kaydetmek için SQL üretir.
3+
*
4+
* NEDEN BİR BETİK: 0041 dinamik kaydı bilerek kapattı — kendi kendine
5+
* kaydolabilen bir istemci yüzeyi, onay ekranında rastgele bir ismin
6+
* görünmesine izin vermek demekti. Ama karşılığında kayıt hiç
7+
* belgelenmedi: `oauth_clients`e satır ekleyen tek kod bir test worker'ı.
8+
* Anime elle girilmiş ve nasıl girildiği hiçbir yerde yazmıyor. Her
9+
* Equinox sitesini Orbit'e bağlayacaksak bu adımın tekrarlanabilir olması
10+
* gerekiyor.
11+
*
12+
* NEDEN SQL BASIYOR, VERİTABANINA YAZMIYOR: yazmak için üretim
13+
* kimlik bilgisi gerekirdi. Betik yalnız metin üretiyor; nereye
14+
* uygulanacağına operatör karar veriyor ve komutu kendi gözüyle görüyor.
15+
*
16+
* SIRLAR STDIN'DEN GELİR, ARGÜMANDAN DEĞİL. Komut satırı argümanları
17+
* `ps` çıktısında ve kabuk geçmişinde görünür. Betik ne peper'ı ne de
18+
* istemci sırrını EKRANA BASAR; çıktıda yalnız özet vardır.
19+
*
20+
* Kullanım:
21+
* printf '%s\n%s\n' "$PEPPER" "$CLIENT_SECRET" \
22+
* | node scripts/orbit-site-client-register.mjs ayar.json
23+
*
24+
* `ayar.json` biçimi:
25+
* {
26+
* "clientId": "orbit-haber",
27+
* "label": "Equinox Haber",
28+
* "siteUrl": "https://haber.sametbasbug.dev",
29+
* "scopes": ["openid", "profile"],
30+
* "redirectUris": ["https://haber.sametbasbug.dev/giris/orbit/donus"],
31+
* "environment": "production"
32+
* }
33+
*/
34+
35+
import { readFileSync } from 'node:fs';
36+
37+
/* Orbit'in `TOKEN_HASH_VERSION` sabitiyle aynı olmak ZORUNDA. Ayrıştığı gün
38+
* satır yazılır ama istemci kimlik doğrulaması sessizce reddedilir. */
39+
const TOKEN_HASH_VERSION = 1;
40+
41+
/* `site-authorization-scopes.ts` ile aynı liste ve aynı SIRA. Sıra önemli:
42+
* kapsamlar veritabanına kanonik sırayla, boşlukla ayrılmış tek metin olarak
43+
* yazılıyor; 'profile openid' ile 'openid profile' iki farklı satır olurdu. */
44+
const SITE_AUTHORIZATION_SCOPES = [
45+
'openid',
46+
'profile',
47+
'email',
48+
'orbit.graph.read',
49+
'orbit.posts.read',
50+
];
51+
52+
function base64Url(bytes) {
53+
let binary = '';
54+
for (const byte of bytes) binary += String.fromCharCode(byte);
55+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
56+
}
57+
58+
/* api.ts:4153 ile birebir aynı: HMAC-SHA256, base64url, dolgusuz, ve aynı
59+
* alan öneki. Önek olmadan aynı peper'la üretilen başka bir özet bu alanda
60+
* geçerli olurdu. */
61+
export async function siteClientSecretDigest(secret, pepper) {
62+
const encoder = new TextEncoder();
63+
const key = await crypto.subtle.importKey(
64+
'raw',
65+
encoder.encode(pepper),
66+
{ name: 'HMAC', hash: 'SHA-256' },
67+
false,
68+
['sign'],
69+
);
70+
const imza = await crypto.subtle.sign(
71+
'HMAC',
72+
key,
73+
encoder.encode(`orbit:site-client-secret:v1:${secret}`),
74+
);
75+
return base64Url(new Uint8Array(imza));
76+
}
77+
78+
/* SQL metin kaçışı. Değerler doğrulamadan geçiyor ama tek tırnak yine de
79+
* kaçırılıyor: doğrulamayı gevşetecek bir sonraki değişiklik burayı
80+
* hatırlamayabilir. */
81+
function q(value) {
82+
return `'${String(value).replaceAll("'", "''")}'`;
83+
}
84+
85+
export function ayariDogrula(ayar) {
86+
const hatalar = [];
87+
const { clientId, label, siteUrl, scopes, redirectUris, environment } = ayar;
88+
89+
if (typeof clientId !== 'string' || clientId.trim().length < 8 || clientId.length > 255) {
90+
hatalar.push('clientId 8-255 karakter olmalı');
91+
}
92+
if (typeof label !== 'string' || label.trim().length < 1 || label.length > 120) {
93+
hatalar.push('label 1-120 karakter olmalı');
94+
}
95+
if (environment !== 'production' && environment !== 'development') {
96+
hatalar.push("environment 'production' veya 'development' olmalı");
97+
}
98+
if (typeof siteUrl !== 'string'
99+
|| !(siteUrl.startsWith('https://') || siteUrl.startsWith('http://localhost'))) {
100+
hatalar.push('siteUrl https:// veya http://localhost ile başlamalı');
101+
}
102+
103+
if (!Array.isArray(scopes) || scopes.length === 0) {
104+
hatalar.push('scopes boş olamaz');
105+
} else {
106+
const taninmayan = scopes.filter((s) => !SITE_AUTHORIZATION_SCOPES.includes(s));
107+
if (taninmayan.length > 0) hatalar.push(`tanınmayan kapsam: ${taninmayan.join(', ')}`);
108+
/* `openid` zorunlu: `sub` claim'i onunla geliyor ve `sub` olmadan site
109+
* kullanıcıyı tanıyamaz. */
110+
if (!scopes.includes('openid')) hatalar.push("scopes 'openid' içermeli");
111+
if (new Set(scopes).size !== scopes.length) hatalar.push('scopes tekrar içeriyor');
112+
}
113+
114+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
115+
hatalar.push('redirectUris boş olamaz');
116+
} else {
117+
for (const uri of redirectUris) {
118+
/* Tetikleyicinin (0041) reddedeceği adresi buraya kadar getirmiyoruz:
119+
* hata mesajını SQLite'tan değil buradan almak daha anlaşılır. */
120+
if (typeof uri !== 'string' || uri.includes('#')) {
121+
hatalar.push(`redirect_uri parça (#) taşıyamaz: ${uri}`);
122+
continue;
123+
}
124+
if (uri.length < 8 || uri.length > 500) {
125+
hatalar.push(`redirect_uri 8-500 karakter olmalı: ${uri}`);
126+
}
127+
const yerelGelistirme = environment === 'development' && uri.startsWith('http://localhost');
128+
if (!uri.startsWith('https://') && !yerelGelistirme) {
129+
hatalar.push(`redirect_uri https:// olmalı (localhost yalnız development'ta): ${uri}`);
130+
}
131+
}
132+
}
133+
134+
if (hatalar.length > 0) throw new Error(`ayar geçersiz:\n - ${hatalar.join('\n - ')}`);
135+
136+
/* Kanonik sıraya diziliyor; girdideki sıra yok sayılıyor. */
137+
return {
138+
clientId: clientId.trim(),
139+
label: label.trim(),
140+
siteUrl,
141+
environment,
142+
redirectUris,
143+
scopes: SITE_AUTHORIZATION_SCOPES.filter((s) => scopes.includes(s)),
144+
};
145+
}
146+
147+
export function sqlUret({ ayar, ozet, satirId, yonlendirmeIdleri, simdi }) {
148+
const satirlar = [
149+
'BEGIN TRANSACTION;',
150+
'',
151+
'INSERT INTO oauth_clients (',
152+
' id, client_id, secret_digest, hash_version, label, site_url,',
153+
' allowed_scopes, environment, status, created_at',
154+
') VALUES (',
155+
` ${q(satirId)}, ${q(ayar.clientId)}, ${q(ozet)}, ${TOKEN_HASH_VERSION},`,
156+
` ${q(ayar.label)}, ${q(ayar.siteUrl)},`,
157+
` ${q(ayar.scopes.join(' '))}, ${q(ayar.environment)}, 'active', ${simdi}`,
158+
');',
159+
'',
160+
];
161+
ayar.redirectUris.forEach((uri, i) => {
162+
satirlar.push(
163+
'INSERT INTO oauth_client_redirect_uris (id, client_id, redirect_uri, created_at)',
164+
`VALUES (${q(yonlendirmeIdleri[i])}, ${q(satirId)}, ${q(uri)}, ${simdi});`,
165+
'',
166+
);
167+
});
168+
satirlar.push('COMMIT;');
169+
return satirlar.join('\n');
170+
}
171+
172+
function kimlikUret() {
173+
return base64Url(crypto.getRandomValues(new Uint8Array(18)));
174+
}
175+
176+
async function main() {
177+
const yol = process.argv[2];
178+
if (!yol) {
179+
console.error('kullanım: … | node scripts/orbit-site-client-register.mjs <ayar.json>');
180+
process.exit(2);
181+
}
182+
183+
const girdi = readFileSync(0, 'utf-8').split('\n');
184+
const pepper = girdi[0]?.trim() ?? '';
185+
const istemciSirri = girdi[1]?.trim() ?? '';
186+
if (!pepper || !istemciSirri) {
187+
console.error('stdin iki satır bekliyor: 1) ORBIT_SITE_TOKEN_PEPPER_V1 2) istemci sırrı');
188+
process.exit(2);
189+
}
190+
/* Zayıf bir istemci sırrı, güçlü bir imzayı anlamsız kılar. */
191+
if (istemciSirri.length < 32) {
192+
console.error('istemci sırrı en az 32 karakter olmalı (öneri: openssl rand -base64 48)');
193+
process.exit(2);
194+
}
195+
196+
const ayar = ayariDogrula(JSON.parse(readFileSync(yol, 'utf-8')));
197+
const ozet = await siteClientSecretDigest(istemciSirri, pepper);
198+
199+
console.log(sqlUret({
200+
ayar,
201+
ozet,
202+
satirId: kimlikUret(),
203+
yonlendirmeIdleri: ayar.redirectUris.map(() => kimlikUret()),
204+
simdi: Date.now(),
205+
}));
206+
}
207+
208+
if (import.meta.url === `file://${process.argv[1]}`) {
209+
main().catch((hata) => {
210+
console.error(String(hata.message ?? hata));
211+
process.exit(1);
212+
});
213+
}

‎scripts/site-clients/haber.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"clientId": "orbit-haber",
3+
"label": "Equinox Haber",
4+
"siteUrl": "https://haber.sametbasbug.dev",
5+
"scopes": ["openid", "profile"],
6+
"redirectUris": ["https://haber.sametbasbug.dev/giris/orbit/donus"],
7+
"environment": "production"
8+
}

0 commit comments

Comments
 (0)