diff --git a/.changeset/public-oidc-provider.md b/.changeset/public-oidc-provider.md new file mode 100644 index 0000000..d9ca60f --- /dev/null +++ b/.changeset/public-oidc-provider.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/types': minor +--- + +Lets an instance tell a native admin tool how to sign in through its OpenID Provider (ADR 0003). `SystemConfigSchema` and `SystemConfigPatchSchema` gain `oidc_cli_client_id`, a string or `null` that defaults to `null`, so a config from an older API still parses. `PublicSystemConfigResponseSchema` gains an optional `oidcProvider: { issuer, cliClientId }`. It is optional because an older API does not send it, and a client must treat its absence as "this instance does not advertise a provider" and offer email code sign-in. The inferred `SystemConfig` type now includes `oidc_cli_client_id`, so code that builds a full `SystemConfig` by hand has to set it. diff --git a/src/schemas/systemConfig/schema.test.ts b/src/schemas/systemConfig/schema.test.ts index 5ef1430..8f8d492 100644 --- a/src/schemas/systemConfig/schema.test.ts +++ b/src/schemas/systemConfig/schema.test.ts @@ -674,6 +674,65 @@ describe('SystemConfigSchema OpenID Provider keys', () => { }); }); +describe('oidc_cli_client_id', () => { + it('is unset when config predates the key, as on the admin read of an older API', () => { + expect(SystemConfigSchema.parse(baseConfig).oidc_cli_client_id).toBeNull(); + }); + + it('is patchable, including clearing it', () => { + expect(SystemConfigPatchSchema.parse({ oidc_cli_client_id: 'seamless-cli' })).toEqual({ + oidc_cli_client_id: 'seamless-cli', + }); + expect(SystemConfigPatchSchema.parse({ oidc_cli_client_id: null })).toEqual({ + oidc_cli_client_id: null, + }); + }); + + it('is not injected into a patch that does not name it', () => { + expect('oidc_cli_client_id' in SystemConfigPatchSchema.parse({ app_name: 'Seamless' })).toBe( + false, + ); + }); + + it.each(['', 42])('refuses %j', (value) => { + expect(SystemConfigPatchSchema.safeParse({ oidc_cli_client_id: value }).success).toBe(false); + expect(SystemConfigSchema.safeParse({ ...baseConfig, oidc_cli_client_id: value }).success).toBe( + false, + ); + }); +}); + +describe('PublicSystemConfigResponseSchema oidcProvider', () => { + const previous = { loginMethods: ['passkey'], recoveryCodes: true, accountRecovery: false }; + const oidcProvider = { issuer: 'https://login.example.com', cliClientId: 'seamless-cli' }; + + it('still parses the answer of an API that predates the field', () => { + const parsed = PublicSystemConfigResponseSchema.parse(previous); + + expect(parsed).toEqual(previous); + expect('oidcProvider' in parsed).toBe(false); + }); + + it('carries the issuer and client when advertised', () => { + expect(PublicSystemConfigResponseSchema.parse({ ...previous, oidcProvider })).toEqual({ + ...previous, + oidcProvider, + }); + }); + + it.each([ + [{ issuer: oidcProvider.issuer }], + [{ cliClientId: oidcProvider.cliClientId }], + [{ issuer: null, cliClientId: oidcProvider.cliClientId }], + [{ ...oidcProvider, cliClientId: '' }], + [{ ...oidcProvider, issuer: 'login' }], + ])('refuses the partial or malformed advertisement %j', (partial) => { + expect( + PublicSystemConfigResponseSchema.safeParse({ ...previous, oidcProvider: partial }).success, + ).toBe(false); + }); +}); + describe('OAuthProviderCreateSchema endpoints', () => { const withoutEndpoints = { id: baseProvider.id, diff --git a/src/schemas/systemConfig/schema.ts b/src/schemas/systemConfig/schema.ts index 34655f7..2457f4b 100644 --- a/src/schemas/systemConfig/schema.ts +++ b/src/schemas/systemConfig/schema.ts @@ -501,6 +501,11 @@ export const SystemConfigSchema = z.object({ oidc_provider_enabled: z.boolean().default(false), /** The provider service's public URL. Never equal to the API's own `ISSUER`. */ oidc_issuer: z.url().nullable().default(null), + /** + * The application a native admin tool signs in with, advertised on the public config. + * The API refuses an application that cannot exchange a sign-in for a session. + */ + oidc_cli_client_id: z.string().min(1).nullable().default(null), /** Lets an admin opt individual provider clients in to shared secrets. */ allow_client_secrets: z.boolean().default(false), third_party_clients_enabled: z.boolean().default(false), @@ -537,6 +542,7 @@ export const SystemConfigPatchSchema = z magic_link_redirect_uris: z.array(RedirectTargetSchema).optional(), oidc_provider_enabled: z.boolean().optional(), oidc_issuer: z.url().nullable().optional(), + oidc_cli_client_id: z.string().min(1).nullable().optional(), allow_client_secrets: z.boolean().optional(), third_party_clients_enabled: z.boolean().optional(), hosted_ui: HostedUiSchema.nullable().optional(), @@ -691,6 +697,14 @@ export const PublicSystemConfigResponseSchema = z.object({ // screens offer "use a recovery code" and "can't sign in?" only where they work. recoveryCodes: z.boolean().optional(), accountRecovery: z.boolean().optional(), + // Optional so a client still parses an API that predates it. Sent whole or not at all: + // an instance without it has no provider sign-in for a native admin tool to offer. + oidcProvider: z + .object({ + issuer: z.url(), + cliClientId: z.string().min(1), + }) + .optional(), }); export type PublicSystemConfigResponse = z.infer;