This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "user_oauth_accounts" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"provider_user_id" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "user_oauth_accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "user_oauth_accounts_provider_provider_user_id_key"
|
||||||
|
ON "user_oauth_accounts"("provider", "provider_user_id");
|
||||||
|
|
||||||
|
CREATE INDEX "user_oauth_accounts_user_id_idx" ON "user_oauth_accounts"("user_id");
|
||||||
|
|
||||||
|
ALTER TABLE "user_oauth_accounts"
|
||||||
|
ADD CONSTRAINT "user_oauth_accounts_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE "user_oauth_accounts" IS '중앙 사용자와 외부 OAuth 제공자 계정을 매핑한다';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."id" IS 'OAuth 계정 매핑 고유 ID';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."user_id" IS '연결된 중앙 사용자 ID';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."provider" IS 'OAuth 제공자 코드. 예: google';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."provider_user_id" IS 'OAuth 제공자가 발급한 사용자 고유 ID';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."email" IS 'OAuth 제공자에서 받은 이메일';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."created_at" IS '매핑 생성 시각';
|
||||||
|
COMMENT ON COLUMN "user_oauth_accounts"."updated_at" IS '매핑 수정 시각';
|
||||||
@@ -44,10 +44,26 @@ model User {
|
|||||||
affiliations UserAffiliation[]
|
affiliations UserAffiliation[]
|
||||||
userLicenses UserLicense[]
|
userLicenses UserLicense[]
|
||||||
appRoles UserAppRole[]
|
appRoles UserAppRole[]
|
||||||
|
oauthAccounts UserOauthAccount[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserOauthAccount {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
provider String
|
||||||
|
providerUserId String @map("provider_user_id")
|
||||||
|
email String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerUserId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("user_oauth_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
model UserAppRole {
|
model UserAppRole {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
|
|||||||
@@ -197,6 +197,89 @@ export async function createUser(input: { email: string; password: string; name?
|
|||||||
return toAuthUser(user)
|
return toAuthUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findOrCreateOAuthUser(input: {
|
||||||
|
provider: string
|
||||||
|
providerUserId: string
|
||||||
|
email: string
|
||||||
|
name?: string | null
|
||||||
|
}): Promise<AuthUser> {
|
||||||
|
const provider = input.provider.toLowerCase()
|
||||||
|
const email = input.email.toLowerCase()
|
||||||
|
|
||||||
|
const oauthAccount = await prisma.userOauthAccount.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_providerUserId: {
|
||||||
|
provider,
|
||||||
|
providerUserId: input.providerUserId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: userSelect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (oauthAccount?.user) return toAuthUser(oauthAccount.user)
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: existingUser.id },
|
||||||
|
data: {
|
||||||
|
oauthAccounts: {
|
||||||
|
create: {
|
||||||
|
provider,
|
||||||
|
providerUserId: input.providerUserId,
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: userSelect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return toAuthUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const freeLicense = await prisma.license.findUnique({
|
||||||
|
where: { code: 'FREE' },
|
||||||
|
select: { defaultDurationDays: true }
|
||||||
|
})
|
||||||
|
const licenseStartsAt = new Date()
|
||||||
|
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await hashPassword(createOpaqueToken()),
|
||||||
|
name: input.name?.trim() || null,
|
||||||
|
oauthAccounts: {
|
||||||
|
create: {
|
||||||
|
provider,
|
||||||
|
providerUserId: input.providerUserId,
|
||||||
|
email
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userLicenses: {
|
||||||
|
create: {
|
||||||
|
startsAt: licenseStartsAt,
|
||||||
|
expiresAt: licenseExpiresAt,
|
||||||
|
license: {
|
||||||
|
connect: { code: 'FREE' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: userSelect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return toAuthUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
|
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
|
||||||
if (!durationDays) return null
|
if (!durationDays) return null
|
||||||
|
|
||||||
@@ -503,6 +586,42 @@ export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser
|
|||||||
return findUserById(token.userId)
|
return findUserById(token.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createOAuthState(redirectUri: string): Promise<string> {
|
||||||
|
const state = createOpaqueToken()
|
||||||
|
await redis.set(oauthStateKey(state), JSON.stringify({ redirectUri }), 'EX', 10 * 60)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeOAuthState(state: string): Promise<{ redirectUri: string } | null> {
|
||||||
|
const tokenJson = await redis.send('GETDEL', [oauthStateKey(state)])
|
||||||
|
if (!tokenJson) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(String(tokenJson)) as { redirectUri?: unknown }
|
||||||
|
return typeof payload.redirectUri === 'string' ? { redirectUri: payload.redirectUri } : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOAuthLoginToken(userId: string): Promise<string> {
|
||||||
|
const token = createOpaqueToken()
|
||||||
|
await redis.set(oauthLoginTokenKey(token), JSON.stringify({ userId }), 'EX', config.oauthLoginTokenTtlSeconds)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeOAuthLoginToken(loginToken: string): Promise<AuthUser | null> {
|
||||||
|
const tokenJson = await redis.send('GETDEL', [oauthLoginTokenKey(loginToken)])
|
||||||
|
if (!tokenJson) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(String(tokenJson)) as { userId?: unknown }
|
||||||
|
return typeof payload.userId === 'string' ? findUserById(payload.userId) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
|
export async function revokeRefreshToken(refreshToken: string): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
redis.del(refreshTokenKey(refreshToken)),
|
redis.del(refreshTokenKey(refreshToken)),
|
||||||
@@ -549,6 +668,8 @@ export async function exchangeHandoffToken(handoffToken: string, audienceApp?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
|
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
|
||||||
|
const oauthStateKey = (state: string): string => `auth:oauth:state:${hashToken(state)}`
|
||||||
|
const oauthLoginTokenKey = (token: string): string => `auth:oauth:login:${hashToken(token)}`
|
||||||
|
|
||||||
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
|
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,18 @@ import {
|
|||||||
clearLoginAttempts,
|
clearLoginAttempts,
|
||||||
assignUserAffiliation,
|
assignUserAffiliation,
|
||||||
changeUserPassword,
|
changeUserPassword,
|
||||||
|
consumeOAuthState,
|
||||||
|
createOAuthLoginToken,
|
||||||
|
createOAuthState,
|
||||||
createManagedUser,
|
createManagedUser,
|
||||||
createOrganization,
|
createOrganization,
|
||||||
createHandoffToken,
|
createHandoffToken,
|
||||||
createRefreshToken,
|
createRefreshToken,
|
||||||
createUser,
|
createUser,
|
||||||
deleteUserProfileImage,
|
deleteUserProfileImage,
|
||||||
|
exchangeOAuthLoginToken,
|
||||||
exchangeHandoffToken,
|
exchangeHandoffToken,
|
||||||
|
findOrCreateOAuthUser,
|
||||||
findUserProfileImage,
|
findUserProfileImage,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
listOrganizations,
|
listOrganizations,
|
||||||
@@ -111,6 +116,10 @@ const handoffExchangeSchema = z.object({
|
|||||||
audienceApp: z.string().min(1).max(120).optional()
|
audienceApp: z.string().min(1).max(120).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const oauthExchangeSchema = z.object({
|
||||||
|
loginToken: z.string().min(1)
|
||||||
|
})
|
||||||
|
|
||||||
const AVATAR_MAX_BYTES = 80 * 1024
|
const AVATAR_MAX_BYTES = 80 * 1024
|
||||||
const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg'])
|
const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg'])
|
||||||
|
|
||||||
@@ -192,6 +201,247 @@ const readRefreshToken = async (c: { req: { header: (name: string) => string | u
|
|||||||
|
|
||||||
export const authRoutes = new Hono<{ Variables: AuthVariables }>()
|
export const authRoutes = new Hono<{ Variables: AuthVariables }>()
|
||||||
|
|
||||||
|
type GoogleTokenResponse = {
|
||||||
|
access_token?: string
|
||||||
|
error?: string
|
||||||
|
error_description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GoogleUserInfo = {
|
||||||
|
sub?: string
|
||||||
|
email?: string
|
||||||
|
email_verified?: boolean
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectOAuthError(redirectUri: string, error: string) {
|
||||||
|
const url = new URL(redirectUri)
|
||||||
|
url.searchParams.set('error', error)
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthCompletePage(targetUrl: string, state: 'success' | 'error', message: string) {
|
||||||
|
const safeTargetUrl = JSON.stringify(targetUrl)
|
||||||
|
const safeMessage = message
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Autonomy Studio 로그인</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #070b12;
|
||||||
|
color: #eef3ff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(59, 127, 250, 0.24), transparent 34%),
|
||||||
|
linear-gradient(145deg, #070b12 0%, #101827 100%);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
width: min(420px, calc(100vw - 40px));
|
||||||
|
padding: 30px 28px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(12, 18, 30, 0.72);
|
||||||
|
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.09);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${state === 'success' ? 'rgba(59, 127, 250, 0.16)' : 'rgba(248, 113, 113, 0.16)'};
|
||||||
|
color: ${state === 'success' ? '#70a5ff' : '#fca5a5'};
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(228, 235, 248, 0.68);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin-top: 22px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #3b7ffa;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #5f96ff; }
|
||||||
|
.hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(228, 235, 248, 0.48);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="mark">${state === 'success' ? '✓' : '!'}</div>
|
||||||
|
<h1>${state === 'success' ? '로그인이 완료되었습니다' : '로그인에 실패했습니다'}</h1>
|
||||||
|
<p>${safeMessage}</p>
|
||||||
|
<button type="button" onclick="openLauncher()">Autonomy Studio 열기</button>
|
||||||
|
<p class="hint">앱이 열렸다면 이 창은 닫아도 됩니다.</p>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const targetUrl = ${safeTargetUrl};
|
||||||
|
function openLauncher() {
|
||||||
|
window.location.href = targetUrl;
|
||||||
|
setTimeout(() => window.close(), 900);
|
||||||
|
}
|
||||||
|
openLauncher();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
authRoutes.get('/google/login', async (c) => {
|
||||||
|
if (!config.googleClientId || !config.googleClientSecret) {
|
||||||
|
return c.json({ error: 'google_oauth_not_configured' }, 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await createOAuthState(config.launcherOAuthRedirectUri)
|
||||||
|
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
|
||||||
|
url.searchParams.set('client_id', config.googleClientId)
|
||||||
|
url.searchParams.set('redirect_uri', config.googleRedirectUri)
|
||||||
|
url.searchParams.set('response_type', 'code')
|
||||||
|
url.searchParams.set('scope', 'openid email profile')
|
||||||
|
url.searchParams.set('state', state)
|
||||||
|
url.searchParams.set('prompt', 'select_account')
|
||||||
|
|
||||||
|
return c.redirect(url.toString(), 302)
|
||||||
|
})
|
||||||
|
|
||||||
|
authRoutes.get('/google/callback', async (c) => {
|
||||||
|
const state = c.req.query('state')
|
||||||
|
const code = c.req.query('code')
|
||||||
|
const error = c.req.query('error')
|
||||||
|
|
||||||
|
const storedState = state ? await consumeOAuthState(state) : null
|
||||||
|
const redirectUri = storedState?.redirectUri ?? config.launcherOAuthRedirectUri
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
redirectOAuthError(redirectUri, error),
|
||||||
|
'error',
|
||||||
|
'Google 인증이 취소되었거나 완료되지 않았습니다.'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (!storedState || !code) {
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
redirectOAuthError(redirectUri, 'invalid_oauth_state'),
|
||||||
|
'error',
|
||||||
|
'로그인 요청 정보를 확인할 수 없습니다. 런처에서 다시 시도해주세요.'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (!config.googleClientId || !config.googleClientSecret) {
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
redirectOAuthError(redirectUri, 'google_oauth_not_configured'),
|
||||||
|
'error',
|
||||||
|
'Google 로그인 설정이 완료되지 않았습니다.'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code,
|
||||||
|
client_id: config.googleClientId,
|
||||||
|
client_secret: config.googleClientSecret,
|
||||||
|
redirect_uri: config.googleRedirectUri,
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenPayload = await tokenResponse.json().catch(() => null) as GoogleTokenResponse | null
|
||||||
|
if (!tokenResponse.ok || !tokenPayload?.access_token) {
|
||||||
|
console.error('Google token exchange failed', tokenPayload)
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
redirectOAuthError(redirectUri, 'google_token_exchange_failed'),
|
||||||
|
'error',
|
||||||
|
'Google 인증 정보를 가져오지 못했습니다.'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfoResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
||||||
|
headers: { authorization: `Bearer ${tokenPayload.access_token}` }
|
||||||
|
})
|
||||||
|
const userInfo = await userInfoResponse.json().catch(() => null) as GoogleUserInfo | null
|
||||||
|
|
||||||
|
if (!userInfoResponse.ok || !userInfo?.sub || !userInfo.email || userInfo.email_verified === false) {
|
||||||
|
console.error('Google userinfo failed', userInfo)
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
redirectOAuthError(redirectUri, 'google_userinfo_failed'),
|
||||||
|
'error',
|
||||||
|
'Google 계정 정보를 확인하지 못했습니다.'
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findOrCreateOAuthUser({
|
||||||
|
provider: 'google',
|
||||||
|
providerUserId: userInfo.sub,
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loggedInAt = new Date()
|
||||||
|
await markLastLogin(user.id, loggedInAt)
|
||||||
|
const loginToken = await createOAuthLoginToken(user.id)
|
||||||
|
const url = new URL(redirectUri)
|
||||||
|
url.searchParams.set('loginToken', loginToken)
|
||||||
|
|
||||||
|
return c.html(oauthCompletePage(
|
||||||
|
url.toString(),
|
||||||
|
'success',
|
||||||
|
'Autonomy Studio로 돌아가는 중입니다.'
|
||||||
|
))
|
||||||
|
})
|
||||||
|
|
||||||
|
authRoutes.post('/oauth/exchange', async (c) => {
|
||||||
|
const body = oauthExchangeSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||||
|
|
||||||
|
const user = await exchangeOAuthLoginToken(body.data.loginToken)
|
||||||
|
if (!user) return c.json({ error: 'invalid_oauth_login_token' }, 401)
|
||||||
|
|
||||||
|
const refreshToken = await createRefreshToken(user.id)
|
||||||
|
setRefreshCookie(c, refreshToken)
|
||||||
|
|
||||||
|
return c.json(await accessTokenResponse(user))
|
||||||
|
})
|
||||||
|
|
||||||
authRoutes.post('/email-verification/send', async (c) => {
|
authRoutes.post('/email-verification/send', async (c) => {
|
||||||
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
|
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
|
||||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||||
|
|||||||
+6
-1
@@ -34,5 +34,10 @@ export const config = {
|
|||||||
smtpSecure: process.env.SMTP_SECURE === 'true',
|
smtpSecure: process.env.SMTP_SECURE === 'true',
|
||||||
smtpUser: process.env.SMTP_USER,
|
smtpUser: process.env.SMTP_USER,
|
||||||
smtpPass: process.env.SMTP_PASS,
|
smtpPass: process.env.SMTP_PASS,
|
||||||
smtpFrom: process.env.SMTP_FROM
|
smtpFrom: process.env.SMTP_FROM,
|
||||||
|
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI ?? 'http://localhost:4100/auth/google/callback',
|
||||||
|
launcherOAuthRedirectUri: process.env.LAUNCHER_OAUTH_REDIRECT_URI ?? 'autonomy-studio://auth/google/callback',
|
||||||
|
oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user