diff --git a/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md b/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md index cc73814..2a6d055 100644 --- a/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md +++ b/openspec/changes/phase-1-mvp/specs/journal-auth/spec.md @@ -1,26 +1,56 @@ ## ADDED Requirements -### Requirement: User registration -The Journal SHALL allow new users to create an account with email and password. +### Requirement: Passkey registration +The Journal SHALL allow new users to register using a passkey (WebAuthn). No password is required. -#### Scenario: Successful registration -- **WHEN** a user submits a valid email and password on the registration page -- **THEN** a new user account is created and the user is logged in +#### Scenario: Successful passkey registration +- **WHEN** a user enters an email and username and creates a passkey via the browser prompt +- **THEN** a new user account is created, the passkey credential is stored, and the user is logged in #### Scenario: Duplicate email - **WHEN** a user submits an email that is already registered - **THEN** the system displays an error indicating the email is already in use -### Requirement: User login -The Journal SHALL allow existing users to log in with email and password. +#### Scenario: Duplicate username +- **WHEN** a user submits a username that is already taken +- **THEN** the system displays an error indicating the username is not available -#### Scenario: Successful login -- **WHEN** a user submits valid credentials +### Requirement: Passkey login +The Journal SHALL allow returning users to log in using a stored passkey. + +#### Scenario: Successful passkey login +- **WHEN** a user clicks "Sign in" and selects a passkey from the browser prompt - **THEN** the user is authenticated and redirected to their activity feed -#### Scenario: Invalid credentials -- **WHEN** a user submits invalid credentials -- **THEN** the system displays an error without revealing which field is wrong +#### Scenario: No passkey available +- **WHEN** a user has no passkey on the current device +- **THEN** the system offers magic link login as a fallback + +### Requirement: Magic link login (fallback) +The Journal SHALL allow users to log in via a magic link sent to their email. This serves as a fallback for devices without passkey support or for logging in on a new device. + +#### Scenario: Request magic link +- **WHEN** a user enters their email and clicks "Send magic link" +- **THEN** an email with a single-use login link is sent to their address + +#### Scenario: Valid magic link +- **WHEN** a user clicks a valid, non-expired magic link +- **THEN** the user is logged in and the link is invalidated + +#### Scenario: Expired magic link +- **WHEN** a user clicks a magic link older than 15 minutes +- **THEN** the system displays an error and prompts them to request a new link + +#### Scenario: Rate limiting +- **WHEN** a user requests more than 5 magic links in 10 minutes +- **THEN** subsequent requests are rejected with a rate limit message + +### Requirement: Add passkey from new device +The Journal SHALL allow logged-in users to register additional passkeys for new devices. + +#### Scenario: Add passkey after magic link login +- **WHEN** a user logs in via magic link on a new device +- **THEN** the system prompts them to register a passkey for that device ### Requirement: User profile page Each user SHALL have a public profile page displaying their username and routes. @@ -50,3 +80,10 @@ The Journal SHALL maintain authenticated sessions using secure HTTP-only cookies #### Scenario: Logout - **WHEN** a user clicks "Log out" - **THEN** their session is invalidated and they are redirected to the login page + +### Requirement: No passwords +The Journal SHALL NOT support password-based authentication. All authentication is via passkeys or magic links. + +#### Scenario: No password field +- **WHEN** a user views the registration or login page +- **THEN** there is no password field diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md index 441d00c..262d81e 100644 --- a/openspec/changes/phase-1-mvp/tasks.md +++ b/openspec/changes/phase-1-mvp/tasks.md @@ -63,13 +63,16 @@ ## 7. Journal — Auth -- [ ] 7.1 Set up PostgreSQL schema (journal.users table with id, email, password_hash, username, bio, created_at) -- [ ] 7.2 Implement registration page and API (POST /api/auth/register) -- [ ] 7.3 Implement login page and API (POST /api/auth/login, session cookie) -- [ ] 7.4 Implement logout (POST /api/auth/logout, invalidate session) -- [ ] 7.5 Implement session middleware (validate cookie, load user in loader context) -- [ ] 7.6 Implement user profile page (GET /users/:username) -- [ ] 7.7 Store federated identity format (@user@domain) in user record +- [ ] 7.1 Update DB schema: remove password_hash from users, add credentials table (WebAuthn) and magic_tokens table +- [ ] 7.2 Implement passkey registration flow (email + username → WebAuthn create → account created) +- [ ] 7.3 Implement passkey login flow (WebAuthn get → session created) +- [ ] 7.4 Implement magic link request (POST /api/auth/magic-link → send email with token) +- [ ] 7.5 Implement magic link verification (GET /auth/verify?token=... → session created) +- [ ] 7.6 Implement "Add passkey" prompt after magic link login on new device +- [ ] 7.7 Implement session middleware (validate cookie, load user in loader context) +- [ ] 7.8 Implement logout (POST /api/auth/logout, invalidate session) +- [ ] 7.9 Implement user profile page (GET /users/:username) +- [ ] 7.10 Store federated identity format (@user@domain) in user record ## 8. Journal — Route Management diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 2fed404..ad2e1a7 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -25,7 +25,6 @@ export const journalSchema = pgSchema("journal"); export const users = journalSchema.table("users", { id: text("id").primaryKey(), email: text("email").notNull().unique(), - passwordHash: text("password_hash").notNull(), username: text("username").notNull().unique(), displayName: text("display_name"), bio: text("bio"), @@ -33,6 +32,28 @@ export const users = journalSchema.table("users", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +export const credentials = journalSchema.table("credentials", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + credentialId: bytea("credential_id").notNull(), + publicKey: bytea("public_key").notNull(), + counter: integer("counter").notNull().default(0), + deviceType: text("device_type"), + transports: jsonb("transports").$type(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const magicTokens = journalSchema.table("magic_tokens", { + id: text("id").primaryKey(), + email: text("email").notNull(), + token: text("token").notNull().unique(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + usedAt: timestamp("used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + export const routes = journalSchema.table("routes", { id: text("id").primaryKey(), ownerId: text("owner_id")