Skip to content

Commit fc929d6

Browse files
gracefullightclaude
andcommitted
feat(web): add email/password auth with resend and session exchange
Add better-auth email/password authentication alongside existing OAuth flow. Frontend uses Resend for verification emails and password resets. Backend adds /session-exchange endpoint for email auth users who lack OAuth provider tokens, verifying better-auth sessions directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 45857ba commit fc929d6

9 files changed

Lines changed: 362 additions & 5 deletions

File tree

.agents/skills/oma-frontend/resources/snippets.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,187 @@ export function StatsGrid({ children }: { children: React.ReactNode }) {
202202

203203
---
204204

205+
## Email Auth - better-auth + Resend (Server Config)
206+
207+
```typescript
208+
// src/lib/auth/auth-server.ts
209+
import { betterAuth } from "better-auth";
210+
import { nextCookies } from "better-auth/next-js";
211+
import { Resend } from "resend";
212+
import { env } from "@/config/env";
213+
214+
const resend = new Resend(env.RESEND_API_KEY);
215+
216+
export const auth = betterAuth({
217+
baseURL: env.BETTER_AUTH_URL,
218+
secret: env.BETTER_AUTH_SECRET,
219+
emailAndPassword: {
220+
enabled: true,
221+
requireEmailVerification: true,
222+
sendResetPassword: async ({ user, url }) => {
223+
void resend.emails.send({
224+
from: env.EMAIL_FROM,
225+
to: user.email,
226+
subject: "비밀번호 재설정",
227+
html: `<a href="${url}">비밀번호 재설정하기</a>`,
228+
});
229+
},
230+
},
231+
emailVerification: {
232+
sendOnSignUp: true,
233+
sendVerificationEmail: async ({ user, url }) => {
234+
void resend.emails.send({
235+
from: env.EMAIL_FROM,
236+
to: user.email,
237+
subject: "이메일 인증",
238+
html: `<a href="${url}">이메일 인증하기</a>`,
239+
});
240+
},
241+
},
242+
socialProviders: {
243+
// ... existing OAuth providers
244+
},
245+
plugins: [nextCookies()],
246+
session: {
247+
expiresIn: 60 * 60 * 24 * 7,
248+
cookieCache: { enabled: true, strategy: "jwe" },
249+
},
250+
trustedOrigins: [env.BETTER_AUTH_URL],
251+
});
252+
```
253+
254+
---
255+
256+
## Email Auth - Client (Sign Up / Sign In / Verify)
257+
258+
```typescript
259+
// src/lib/auth/auth-client.ts
260+
import { createAuthClient } from "better-auth/react";
261+
import { env } from "@/config/env";
262+
263+
export const authClient = createAuthClient({
264+
baseURL: env.NEXT_PUBLIC_BETTER_AUTH_URL,
265+
});
266+
267+
// Sign up with email
268+
export async function signUpWithEmail(email: string, password: string, name: string) {
269+
return authClient.signUp.email({
270+
email,
271+
password,
272+
name,
273+
callbackURL: "/",
274+
});
275+
}
276+
277+
// Sign in with email
278+
export async function signInWithEmail(email: string, password: string) {
279+
return authClient.signIn.email(
280+
{ email, password },
281+
{
282+
onError: (ctx) => {
283+
if (ctx.error.status === 403) {
284+
// Email not verified - show verification prompt
285+
}
286+
},
287+
},
288+
);
289+
}
290+
291+
// Resend verification email
292+
export async function resendVerificationEmail(email: string) {
293+
return authClient.sendVerificationEmail({
294+
email,
295+
callbackURL: "/",
296+
});
297+
}
298+
299+
// Request password reset
300+
export async function requestPasswordReset(email: string) {
301+
return authClient.requestPasswordReset({
302+
email,
303+
redirectTo: "/reset-password",
304+
});
305+
}
306+
307+
// Reset password with token
308+
export async function resetPassword(token: string, newPassword: string) {
309+
return authClient.resetPassword({
310+
newPassword,
311+
token,
312+
});
313+
}
314+
```
315+
316+
---
317+
318+
## Email Auth - Backend Token Exchange (Bridge)
319+
320+
Email auth에는 OAuth provider token이 없으므로, better-auth 세션 기반으로 백엔드 JWE 토큰을 교환합니다.
321+
322+
```typescript
323+
// src/lib/auth/auth-client.ts - exchangeSessionForBackendJwt 추가
324+
export async function exchangeSessionForBackendJwt() {
325+
const { data: session } = await authClient.getSession();
326+
if (!session?.user?.email) return;
327+
328+
// email auth 유저는 OAuth provider가 없음
329+
// better-auth 세션 토큰으로 백엔드에 직접 인증 요청
330+
const sessionToken = document.cookie
331+
.split("; ")
332+
.find((row) => row.startsWith("better-auth.session_token="))
333+
?.split("=")[1];
334+
335+
if (!sessionToken) return;
336+
337+
const { data } = await apiClient.post<BackendTokenResponse>(
338+
"/api/auth/session-exchange",
339+
{ session_token: sessionToken },
340+
);
341+
342+
if (data?.access_token) setAccessToken(data.access_token);
343+
if (data?.refresh_token) setRefreshToken(data.refresh_token);
344+
}
345+
```
346+
347+
```typescript
348+
// src/app/providers.tsx - BackendJwtBridge 수정
349+
function BackendJwtBridge() {
350+
const { data: session, isPending } = useSession();
351+
const user = session?.user;
352+
353+
useEffect(() => {
354+
if (isPending) return;
355+
if (!user) {
356+
if (hasBackendAccessToken()) clearBackendTokens();
357+
return;
358+
}
359+
if (hasBackendAccessToken()) return;
360+
361+
// OAuth 유저는 기존 flow, email 유저는 session exchange
362+
exchangeOAuthForBackendJwt()
363+
.catch(() => exchangeSessionForBackendJwt())
364+
.catch(() => {});
365+
}, [isPending, user]);
366+
367+
return null;
368+
}
369+
```
370+
371+
---
372+
373+
## Email Auth - Environment Variables
374+
375+
```typescript
376+
// src/config/env.ts - 추가할 환경변수
377+
server: {
378+
// ... existing
379+
RESEND_API_KEY: z.string().min(1),
380+
EMAIL_FROM: z.string().email().optional().default("noreply@yourdomain.com"),
381+
},
382+
```
383+
384+
---
385+
205386
## Vitest Component Test
206387

207388
```tsx

apps/api/src/auth/router.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from src.lib.auth import (
66
OAuthLoginRequest,
77
RefreshTokenRequest,
8+
SessionExchangeRequest,
89
TokenResponse,
910
decode_token,
1011
verify_oauth_token,
12+
verify_session_token,
1113
)
1214
from src.lib.dependencies import DBSession
1315
from src.users.model import User
@@ -53,6 +55,45 @@ async def login(
5355
)
5456

5557

58+
@router.post("/session-exchange", response_model=TokenResponse)
59+
async def session_exchange(
60+
request: SessionExchangeRequest,
61+
db: DBSession,
62+
) -> TokenResponse:
63+
"""Exchange better-auth session token for backend JWE tokens.
64+
65+
Used by email/password auth users who have no OAuth provider token.
66+
Verifies session with better-auth server, then issues backend tokens.
67+
"""
68+
user_info = await verify_session_token(request.session_token)
69+
70+
from sqlalchemy import select
71+
72+
result = await db.execute(select(User).where(User.email == user_info.email))
73+
user = result.scalar_one_or_none()
74+
75+
if not user:
76+
user = User(
77+
email=user_info.email,
78+
name=user_info.name,
79+
image=user_info.image,
80+
email_verified=user_info.email_verified,
81+
)
82+
db.add(user)
83+
await db.commit()
84+
await db.refresh(user)
85+
86+
from src.lib.auth import create_access_token, create_refresh_token
87+
88+
access_token = create_access_token(str(user.id))
89+
refresh_token = create_refresh_token(str(user.id))
90+
91+
return TokenResponse(
92+
access_token=access_token,
93+
refresh_token=refresh_token,
94+
)
95+
96+
5697
@router.post("/refresh", response_model=TokenResponse)
5798
async def refresh_token(
5899
request: RefreshTokenRequest,

apps/api/src/lib/auth.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ class OAuthLoginRequest(BaseModel):
3939
name: str | None = None
4040

4141

42+
class SessionExchangeRequest(BaseModel):
43+
"""Exchange better-auth session token for backend JWE tokens."""
44+
45+
session_token: str
46+
47+
4248
class RefreshTokenRequest(BaseModel):
4349
"""Refresh token request."""
4450

@@ -204,6 +210,36 @@ async def verify_facebook_token(access_token: str) -> OAuthUserInfo:
204210
)
205211

206212

213+
async def verify_session_token(session_token: str) -> OAuthUserInfo:
214+
"""Verify better-auth session token and extract user info."""
215+
async with httpx.AsyncClient() as client:
216+
response = await client.get(
217+
f"{settings.BETTER_AUTH_URL}/api/auth/get-session",
218+
headers={"Cookie": f"better-auth.session_token={session_token}"},
219+
timeout=5.0,
220+
)
221+
if response.status_code != 200:
222+
raise HTTPException(
223+
status_code=status.HTTP_401_UNAUTHORIZED,
224+
detail="Invalid session token",
225+
)
226+
data = response.json()
227+
user = data.get("user", {})
228+
email = user.get("email")
229+
if not email:
230+
raise HTTPException(
231+
status_code=status.HTTP_401_UNAUTHORIZED,
232+
detail="Invalid session: no email",
233+
)
234+
return OAuthUserInfo(
235+
id=user.get("id", ""),
236+
email=email,
237+
name=user.get("name"),
238+
image=user.get("image"),
239+
email_verified=user.get("emailVerified", False),
240+
)
241+
242+
207243
async def verify_oauth_token(provider: str, access_token: str) -> OAuthUserInfo:
208244
"""Verify OAuth token based on provider."""
209245
if provider == "google":

apps/web/bun.lock

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)