Skip to content

Commit ba3f09b

Browse files
sametbasbugclaude
andcommitted
Register site clients through a platform-owner endpoint
The SQL script from 83faba6 needed ORBIT_SITE_TOKEN_PEPPER_V1 to compute the secret digest, and that pepper is a Worker secret -- write-only, not readable back. Getting it out would have meant rotating it, and rotation invalidates every registered client secret, including the one anime's sign-in depends on. There is no dual-read path in the code: the pepper is read from a single fixed binding and hash_version, though present on the table, is never branched on. So rotation today is a hard cutover. None of that is necessary. The problem was never needing the pepper, only needing a digest computed with it, and Orbit can compute that itself. The endpoint is guarded by requirePlatformOwner, which production grants to exactly one account. The pepper never leaves the Worker. 0041's decision stands: registration is still deliberate and operator- driven, not self-service. What changes is that it is now repeatable, and recorded. Validation and the digest moved into a module both the endpoint and the script import, so the two paths cannot drift into disagreeing about what a valid declaration is. The script stays for local development, where there is no running Orbit and no platform-owner session to authenticate with; it now runs under tsx because plain node will not resolve the extensionless TS imports. The decisive test posts a bogus code to /v1/oauth/token using the newly registered credentials and asserts invalid_grant, not invalid_client: client authentication passed, so the digest matches what verification expects. A control group with a wrong secret gets invalid_client, and a third case shows the first secret still works after a duplicate registration is refused with 409 -- silently overwriting would have knocked an existing site offline with no trace of why. Also found while writing them: Orbit checks the Origin header on top of the CSRF token, so a request without one is refused with origin_forbidden. 24 registration cases pass, the core suite is 228 green, astro check reports 0 errors -- verified that check actually covers these files by introducing a deliberate type error and watching it fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 83faba6 commit ba3f09b

8 files changed

Lines changed: 624 additions & 115 deletions

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@
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-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",
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-client-registration-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-client-registration-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",
4444
"preview": "astro preview",
4545
"check": "npm run orbit:validate && astro check",
4646
"orbit:validate": "node scripts/orbit-validate.mjs",
4747
"words:build": "tsx scripts/build-blocked-words.ts",
48+
"site-client:sql": "tsx scripts/orbit-site-client-register.mjs",
4849
"production:config:check": "node scripts/orbit-production-config-tests.mjs",
4950
"orbit:index": "node scripts/orbit-index.mjs",
5051
"orbit:test:content": "node scripts/orbit-content-tests.mjs",

‎scripts/orbit-site-client-register-tests.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import { strict as assert } from 'node:assert';
1010
import { test } from 'node:test';
1111

1212
import { hmacDigest } from '../src/server/identity/tokens';
13+
import { sqlUret } from './orbit-site-client-register.mjs';
1314
import {
14-
ayariDogrula,
15+
normalizeSiteClientDeclaration as ayariDogrula,
1516
siteClientSecretDigest,
16-
sqlUret,
17-
} from './orbit-site-client-register.mjs';
17+
} from '../src/server/identity/site-client-registration';
1818

1919
const GECERLI = {
2020
clientId: 'orbit-haber',
@@ -65,21 +65,21 @@ test('openid olmadan reddediliyor', () => {
6565
test('tanınmayan kapsam reddediliyor', () => {
6666
assert.throws(
6767
() => ayariDogrula({ ...GECERLI, scopes: ['openid', 'orbit.dm.read'] }),
68-
/tanınmayan kapsam/u,
68+
/unknown scope/u,
6969
);
7070
});
7171

7272
test('parça taşıyan yönlendirme adresi reddediliyor', () => {
7373
assert.throws(
7474
() => ayariDogrula({ ...GECERLI, redirectUris: ['https://x.dev/geri#kod'] }),
75-
/parça/u,
75+
/fragment/u,
7676
);
7777
});
7878

7979
test('production ortamında localhost reddediliyor', () => {
8080
assert.throws(
8181
() => ayariDogrula({ ...GECERLI, redirectUris: ['http://localhost:4321/geri'] }),
82-
/https:\/\/ olmalı/u,
82+
/must be https/u,
8383
);
8484
});
8585

@@ -96,7 +96,7 @@ test('development ortamında localhost kabul ediliyor', () => {
9696
test('düz http uzak adres reddediliyor', () => {
9797
assert.throws(
9898
() => ayariDogrula({ ...GECERLI, redirectUris: ['http://haber.sametbasbug.dev/geri'] }),
99-
/https:\/\/ olmalı/u,
99+
/must be https/u,
100100
);
101101
});
102102

‎scripts/orbit-site-client-register.mjs‎

Lines changed: 23 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@
99
* Equinox sitesini Orbit'e bağlayacaksak bu adımın tekrarlanabilir olması
1010
* gerekiyor.
1111
*
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.
12+
* ÜRETİM İÇİN BU BETİK DEĞİL, `POST /v1/site-clients` KULLANILIYOR: özet
13+
* peper'la hesaplanıyor ve peper bir Worker sırrı, geri okunamıyor. Uç onu
14+
* Worker'ın içinde hesaplıyor, yani peper hiç dışarı çıkmıyor.
15+
*
16+
* Betik YEREL GELİŞTİRME için duruyor: elinde çalışan bir Orbit ve
17+
* `platform_owner` oturumu yokken yerel D1'e istemci eklemenin kısa yolu.
18+
* Doğrulama ve özet hesabı ucun kullandığı modülden geliyor, yani iki yol
19+
* aynı kuralları uyguluyor.
1520
*
1621
* SIRLAR STDIN'DEN GELİR, ARGÜMANDAN DEĞİL. Komut satırı argümanları
1722
* `ps` çıktısında ve kabuk geçmişinde görünür. Betik ne peper'ı ne de
1823
* istemci sırrını EKRANA BASAR; çıktıda yalnız özet vardır.
1924
*
20-
* Kullanım:
25+
* Kullanım (tsx ile — düz `node` uzantısız TS import'unu çözemiyor):
2126
* printf '%s\n%s\n' "$PEPPER" "$CLIENT_SECRET" \
22-
* | node scripts/orbit-site-client-register.mjs ayar.json
27+
* | npm run site-client:sql -- ayar.json
2328
*
2429
* `ayar.json` biçimi:
2530
* {
@@ -34,116 +39,31 @@
3439

3540
import { readFileSync } from 'node:fs';
3641

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;
42+
/* Doğrulama ve özet ucun kullandığı modülden. İkinci bir kopya olsaydı biri
43+
* gevşetildiğinde hangi kuralın geçerli olduğu, isteğin hangi yoldan geldiğine
44+
* bağlı hale gelirdi. */
45+
import {
46+
MIN_CLIENT_SECRET_LENGTH,
47+
normalizeSiteClientDeclaration,
48+
siteClientSecretDigest,
49+
} from '../src/server/identity/site-client-registration.ts';
4050

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+
/* `TOKEN_HASH_VERSION` ile aynı olmak zorunda. */
52+
const TOKEN_HASH_VERSION = 1;
5153

5254
function base64Url(bytes) {
5355
let binary = '';
5456
for (const byte of bytes) binary += String.fromCharCode(byte);
5557
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '');
5658
}
5759

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-
7860
/* SQL metin kaçışı. Değerler doğrulamadan geçiyor ama tek tırnak yine de
7961
* kaçırılıyor: doğrulamayı gevşetecek bir sonraki değişiklik burayı
8062
* hatırlamayabilir. */
8163
function q(value) {
8264
return `'${String(value).replaceAll("'", "''")}'`;
8365
}
8466

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-
14767
export function sqlUret({ ayar, ozet, satirId, yonlendirmeIdleri, simdi }) {
14868
const satirlar = [
14969
'BEGIN TRANSACTION;',
@@ -188,12 +108,12 @@ async function main() {
188108
process.exit(2);
189109
}
190110
/* 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)');
111+
if (istemciSirri.length < MIN_CLIENT_SECRET_LENGTH) {
112+
console.error(`istemci sırrı en az ${MIN_CLIENT_SECRET_LENGTH} karakter olmalı (öneri: openssl rand -base64 48)`);
193113
process.exit(2);
194114
}
195115

196-
const ayar = ayariDogrula(JSON.parse(readFileSync(yol, 'utf-8')));
116+
const ayar = normalizeSiteClientDeclaration(JSON.parse(readFileSync(yol, 'utf-8')));
197117
const ozet = await siteClientSecretDigest(istemciSirri, pepper);
198118

199119
console.log(sqlUret({

0 commit comments

Comments
 (0)