Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12

This commit is contained in:
Sylvain Duchesne
2026-10-12 13:12:38 +02:00
parent 18fc71ff36
commit a61c8704c9
13 changed files with 882 additions and 827 deletions
+6 -6
View File
@@ -13,23 +13,23 @@ test('clientId uses explicit backendClientId when set', () => {
expect(cfg.redirectUri).toBe('http://localhost:3000/login');
});
test('clientId derives from backendUrl when backendClientId is empty', () => {
test('clientId derives the actor uri from backendUrl when backendClientId is empty', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/app');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
});
test('clientId falls back to origin/app when nothing is configured', () => {
test('clientId falls back to the origin actor uri when nothing is configured', () => {
setRuntimeConfig({});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/app');
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
});
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/app');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
});
test('podProviderUrl is passed through when set', () => {
@@ -45,7 +45,7 @@ test('whitespace-only runtime values are treated as empty', () => {
podProviderUrl: ' ',
});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/app');
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
expect(cfg.podProviderUrl).toBeUndefined();
});
+19 -2
View File
@@ -109,17 +109,34 @@ export async function urlStatus(url: string): Promise<number> {
}
}
/** Sign in to the local Pod provider and read the actor's outbox/storage. */
/** Sign in to the local Pod provider and read the actor's outbox/storage.
* On a fresh stack the account does not exist yet: `account.not-found` falls back
* to the cookbook signup, then logs in — the smoke stays reproducible from a wipe. */
export async function podLogin(
config: ApodsStackConfig,
username: string,
password: string,
): Promise<ApodsSession> {
const login = await request(`${config.podProviderUrl}/auth/login`, {
let login = await request(`${config.podProviderUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (login.status === 401 && login.text.includes('account.not-found')) {
const signup = await request(`${config.podProviderUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email: `${username}@test.com`, password, name: username, 'schema:knowsLanguage': 'en' }),
});
if (signup.status !== 200) {
throw new Error(`Pod signup failed: HTTP ${signup.status} ${signup.text.slice(0, 200)}`);
}
login = await request(`${config.podProviderUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
}
if (login.status !== 200) {
throw new Error(`Pod login failed: HTTP ${login.status} ${login.text.slice(0, 200)}`);
}
+10 -5
View File
@@ -39,17 +39,22 @@ export interface ResolvedAuthConfig {
* Resolve the auth configuration from runtime values.
*
* - `backendClientId` wins when explicitly set.
* - Otherwise `backendUrl` derives `${backendUrl}/app`.
* - Otherwise fall back to `${origin}/app` so a page without a configured
* - Otherwise `backendUrl` derives `${backendUrl}/as/actor/app` — the app ACTOR uri, and
* this is not negotiable: the pod provider registers the app under its actor uri, and
* the auth provider's `registerApp` discovers the registration through the pod
* auth-agent's `Link: rel="interop:registeredAgent"` header, which the pod only returns
* when the requesting app's identity (the token's azp, i.e. this client_id) matches the
* registration. client_id and actor uri MUST be one and the same.
* - Otherwise fall back to `${origin}/as/actor/app` so a page without a configured
* backend still typechecks and can be exercised.
*/
export function resolveActivityPodsAuthConfig(origin: string): ResolvedAuthConfig {
const backendClientId = runtimeConfig.backendClientId?.trim();
// Strip trailing slashes first: appending `/app` to `http://host/` would double
// the slash (`http://host//app`), and the client id must be the exact URI the
// Strip trailing slashes first: appending a path to `http://host/` would double
// the slash (`http://host//as/...`), and the client id must be the exact URI the
// backend serves.
const backendUrl = runtimeConfig.backendUrl?.trim().replace(/\/+$/, '');
const clientId = backendClientId || (backendUrl ? `${backendUrl}/app` : `${origin}/app`);
const clientId = backendClientId || (backendUrl ? `${backendUrl}/as/actor/app` : `${origin}/as/actor/app`);
return {
clientId,
redirectUri: `${origin}/login`,