feat: self-serve signup Phase 2 (frontend cutover) (#162)
Some checks failed
CI / e2e (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
Mirror to GitHub / mirror (push) Has been cancelled

Co-authored-by: Michael Chihlas <michael@resolutionflow.com>
Co-committed-by: Michael Chihlas <michael@resolutionflow.com>
This commit was merged in pull request #162.
This commit is contained in:
2026-05-07 18:42:20 +00:00
committed by chihlasm
parent f918b766b0
commit f1be3abcc5
123 changed files with 11563 additions and 559 deletions

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { encodeOAuthState, decodeOAuthState } from './oauthState'
describe('oauthState', () => {
it('round-trips ASCII payloads', () => {
const encoded = encodeOAuthState({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
expect(encoded).not.toContain('+')
expect(encoded).not.toContain('/')
expect(encoded).not.toContain('=')
expect(decodeOAuthState(encoded)).toEqual({
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@example.com',
})
})
it('round-trips non-Latin-1 email characters without throwing', () => {
// Pre-fix: btoa(json) throws DOMException on code points > 255.
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: 'user@münchen.de',
}
const encoded = encodeOAuthState(payload)
expect(decodeOAuthState(encoded)).toEqual(payload)
})
it('round-trips emoji and CJK characters', () => {
const payload = {
csrf: 'abc123',
accountInviteCode: 'CODE12345',
invitedEmail: '日本語+🎉@例え.jp',
}
expect(decodeOAuthState(encodeOAuthState(payload))).toEqual(payload)
})
it('returns null for legacy raw-hex CSRF state (not JSON)', () => {
expect(decodeOAuthState('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull()
})
it('returns null for null / empty input', () => {
expect(decodeOAuthState(null)).toBeNull()
expect(decodeOAuthState('')).toBeNull()
})
it('returns null for malformed base64', () => {
expect(decodeOAuthState('!!!not-base64!!!')).toBeNull()
})
})

View File

@@ -0,0 +1,61 @@
/**
* UTF-8-safe base64url encoding for OAuth `state` payloads.
*
* The /accept-invite flow round-trips an invite code + invited email through
* the OAuth provider's `state` parameter. Internationalized email addresses
* (e.g., `user@münchen.de`) contain code points > 255, which raw `btoa` /
* `atob` cannot represent — they throw `DOMException: The string to be
* encoded contains characters outside of the Latin1 range`.
*
* The classic `unescape(encodeURIComponent(...))` trick maps a UTF-16 string
* through its UTF-8 byte representation into a Latin-1 string that `btoa`
* accepts. The decode side reverses the transformation.
*/
export interface OAuthStatePayload {
csrf: string
accountInviteCode: string
invitedEmail: string
}
export interface DecodedOAuthState {
csrf: string
accountInviteCode?: string
invitedEmail?: string
}
/** Encode an OAuth state payload as URL-safe base64. UTF-8 safe. */
export function encodeOAuthState(payload: OAuthStatePayload): string {
const json = JSON.stringify(payload)
// unescape(encodeURIComponent(...)) converts UTF-16 -> UTF-8 -> Latin-1
// string so btoa can encode it without throwing on non-Latin-1 chars.
const b64 = btoa(unescape(encodeURIComponent(json)))
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/** Best-effort base64url-decode. Returns null on legacy random-hex states or
* malformed input so the caller can fall back to a simple equality check. */
export function decodeOAuthState(raw: string | null): DecodedOAuthState | null {
if (!raw) return null
try {
const padded = raw.replace(/-/g, '+').replace(/_/g, '/')
const b64 = padded + '='.repeat((4 - (padded.length % 4)) % 4)
// decodeURIComponent(escape(...)) reverses the encode-side transform.
const json = decodeURIComponent(escape(atob(b64)))
const parsed = JSON.parse(json) as Partial<DecodedOAuthState>
if (typeof parsed?.csrf === 'string') {
return {
csrf: parsed.csrf,
accountInviteCode:
typeof parsed.accountInviteCode === 'string'
? parsed.accountInviteCode
: undefined,
invitedEmail:
typeof parsed.invitedEmail === 'string' ? parsed.invitedEmail : undefined,
}
}
return null
} catch {
return null
}
}