add google oauth login
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 11:45:35 +09:00
parent 7c67e53b1e
commit ea5eac64c7
5 changed files with 421 additions and 1 deletions
+250
View File
@@ -11,13 +11,18 @@ import {
clearLoginAttempts,
assignUserAffiliation,
changeUserPassword,
consumeOAuthState,
createOAuthLoginToken,
createOAuthState,
createManagedUser,
createOrganization,
createHandoffToken,
createRefreshToken,
createUser,
deleteUserProfileImage,
exchangeOAuthLoginToken,
exchangeHandoffToken,
findOrCreateOAuthUser,
findUserProfileImage,
findUserByEmail,
listOrganizations,
@@ -111,6 +116,10 @@ const handoffExchangeSchema = z.object({
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_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 }>()
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
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) => {
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)