Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/public-oidc-provider.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 59 additions & 0 deletions src/schemas/systemConfig/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions src/schemas/systemConfig/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<typeof PublicSystemConfigResponseSchema>;
Loading