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
20 changes: 14 additions & 6 deletions app/actions/auth/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { redirect } from 'remix/response/redirect'
import { Session } from 'remix/session'

import { config } from '../../config.ts'
import { DomainError } from '../../services/errors.ts'
import { requireGuest, setSessionUser } from '../../middleware/auth.ts'
import { getCsrfToken } from '../../middleware/csrf.ts'
import { rateLimit } from '../../middleware/rate-limit.ts'
Expand Down Expand Up @@ -170,12 +171,19 @@ export default function authController(rateLimits: boolean) {
context.session.flash('error', 'Google account has no email address')
return redirect(routes.auth.loginPage.href(), 303)
}
let user = await context.services.auth.loginExternal({
email: result.profile.email,
name: result.profile.name ?? null,
avatar: result.profile.picture ?? null,
emailVerified: result.profile.email_verified ?? false,
})
let user
try {
user = await context.services.auth.loginExternal({
email: result.profile.email,
name: result.profile.name ?? null,
avatar: result.profile.picture ?? null,
emailVerified: result.profile.email_verified ?? false,
})
} catch (error) {
if (!(error instanceof DomainError)) throw error
context.session.flash('error', error.message)
return redirect(routes.auth.loginPage.href(), 303)
}
let session = completeAuth(context)
setSessionUser(session, user)
return redirect(routes.app.dashboard.href(), 303)
Expand Down
4 changes: 4 additions & 0 deletions app/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export class UserRepository {
this.db.run(`update users set ${sets.join(', ')} where id = ?`, params)
}

markVerified(id: string): void {
this.db.run('update users set is_verified = 1, updated_at = ? where id = ?', [Date.now(), id])
}

updatePassword(id: string, hashedPassword: string): void {
this.db.run('update users set password = ?, updated_at = ? where id = ?', [
hashedPassword,
Expand Down
18 changes: 17 additions & 1 deletion app/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ export class AuthService {
return toAuthUser(user)
}

/** Login/registration via a verified external identity provider (e.g. Google). */
/**
* Login/registration via an external identity provider (e.g. Google).
*
* An existing local account is only linked when the provider says the email is verified.
* If that local account was never verified, nobody has proven they own the address, so
* whoever registered it may not be the provider's user: the old password is replaced with an
* unusable one and its sessions are revoked before the provider's user is signed in.
*/
async loginExternal(profile: {
email: string
name?: string | null
Expand All @@ -78,6 +85,15 @@ export class AuthService {
}): Promise<AuthUser> {
let email = profile.email.trim().toLowerCase()
let user = this.users.findByEmail(email)
if (user && !profile.emailVerified) {
throw errors.unauthorized('Sign in with your password to use this account')
}
if (user && !user.is_verified) {
this.users.updatePassword(user.id, await hashPassword(randomUUID()))
this.users.markVerified(user.id)
this.sessions.deleteByUserId(user.id)
user = this.users.findById(user.id)!
}
if (!user) {
user = this.users.create({
id: randomUUID(),
Expand Down
26 changes: 25 additions & 1 deletion test/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,34 @@ describe('AuthService', () => {

it('external login creates the account once', async () => {
let a = await auth.loginExternal({ email: 'g@x.com', name: 'G', emailVerified: true })
let b = await auth.loginExternal({ email: 'G@x.com' })
let b = await auth.loginExternal({ email: 'G@x.com', emailVerified: true })
assert.equal(a.id, b.id)
assert.equal(a.is_verified, true)
})

it('external login does not link an account when the provider has not verified the email', async () => {
await auth.register({ name: 'A', email: 'a@x.com', password: 'secret1' })
await assert.rejects(
auth.loginExternal({ email: 'a@x.com', emailVerified: false }),
(e: unknown) => e instanceof DomainError && e.kind === 'unauthorized',
)
})

it('external login takes over an unverified local account from whoever registered it', async () => {
let squatter = await auth.register({ name: 'S', email: 'owner@x.com', password: 'squatter1' })
assert.equal(squatter.is_verified, false)
repos.sessions.upsert('squatter-session', squatter.id, '[{},{}]', Date.now() + 10000)

let owner = await auth.loginExternal({ email: 'owner@x.com', name: 'Owner', emailVerified: true })

assert.equal(owner.id, squatter.id)
assert.equal(owner.is_verified, true)
assert.equal(repos.sessions.countByUserId(owner.id), 0)
await assert.rejects(
auth.login({ identifier: 'owner@x.com', password: 'squatter1' }),
(e: unknown) => e instanceof DomainError && e.kind === 'unauthorized',
)
})
})

describe('UserService', () => {
Expand Down
Loading