diff --git a/.changeset/oauth-state-binding.md b/.changeset/oauth-state-binding.md new file mode 100644 index 0000000..8ae3ac6 --- /dev/null +++ b/.changeset/oauth-state-binding.md @@ -0,0 +1,8 @@ +--- +'@seamless-auth/types': minor +--- + +Add `binding` to the OAuth start response and the callback request. The auth server returns a +random `binding` from `/oauth/:providerId/start` and checks it on `/oauth/:providerId/callback`, +so a `state` can only be finished by the client that started the sign-in. Both fields are +optional in the schemas so a client can still parse a response from an older auth server. diff --git a/src/schemas/oauth/schema.test.ts b/src/schemas/oauth/schema.test.ts index eeb8071..3ca9030 100644 --- a/src/schemas/oauth/schema.test.ts +++ b/src/schemas/oauth/schema.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest'; import { + FinishOAuthLoginRequestSchema, OAuthLoginErrorResponseSchema, OAuthLoginSuccessResponseSchema, StartOAuthLoginRequestSchema, + StartOAuthLoginResponseSchema, } from './schema.js'; describe('StartOAuthLoginRequestSchema', () => { @@ -93,3 +95,33 @@ describe('nextStep on the other sign-ins', () => { ).toBe('enroll_passkey'); }); }); + +describe('StartOAuthLoginResponseSchema', () => { + const base = { + provider: { id: 'google', name: 'Google', scopes: ['openid'] }, + state: 'payload.signature', + authorizationUrl: 'https://accounts.example.com/authorize', + }; + + it('carries the binding', () => { + expect(StartOAuthLoginResponseSchema.parse({ ...base, binding: 'b1' }).binding).toBe('b1'); + }); + + it('parses a response from a server that sends no binding', () => { + expect(StartOAuthLoginResponseSchema.parse(base).binding).toBeUndefined(); + }); +}); + +describe('FinishOAuthLoginRequestSchema', () => { + it('accepts a binding', () => { + expect( + FinishOAuthLoginRequestSchema.parse({ code: 'c', state: 's', binding: 'b1' }).binding, + ).toBe('b1'); + }); + + it('refuses an empty binding', () => { + expect(() => + FinishOAuthLoginRequestSchema.parse({ code: 'c', state: 's', binding: ' ' }), + ).toThrow(); + }); +}); diff --git a/src/schemas/oauth/schema.ts b/src/schemas/oauth/schema.ts index 39f36bd..4ec3c89 100644 --- a/src/schemas/oauth/schema.ts +++ b/src/schemas/oauth/schema.ts @@ -35,6 +35,12 @@ export const StartOAuthLoginResponseSchema = z.object({ provider: PublicOAuthProviderSchema, state: z.string(), authorizationUrl: z.url(), + /** + * Binds `state` to the client that started the sign-in. The client keeps it and sends + * it back with the callback; it never goes to the provider. Optional only so a client + * can parse a response from an auth server that predates it. + */ + binding: z.string().optional(), }); export type StartOAuthLoginResponse = z.infer; @@ -42,6 +48,8 @@ export type StartOAuthLoginResponse = z.infer;