@sena-ai/platform-core 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +9 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +147 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/handler.d.ts +19 -0
- package/dist/auth/handler.d.ts.map +1 -0
- package/dist/auth/handler.js +213 -0
- package/dist/auth/handler.js.map +1 -0
- package/dist/auth/session.d.ts +16 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +54 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/db/d1/index.d.ts +14 -0
- package/dist/db/d1/index.d.ts.map +1 -0
- package/dist/db/d1/index.js +252 -0
- package/dist/db/d1/index.js.map +1 -0
- package/dist/db/d1/schema.d.ts +610 -0
- package/dist/db/d1/schema.d.ts.map +1 -0
- package/dist/db/d1/schema.js +58 -0
- package/dist/db/d1/schema.js.map +1 -0
- package/dist/db/mysql/index.d.ts +14 -0
- package/dist/db/mysql/index.d.ts.map +1 -0
- package/dist/db/mysql/index.js +248 -0
- package/dist/db/mysql/index.js.map +1 -0
- package/dist/db/mysql/schema.d.ts +562 -0
- package/dist/db/mysql/schema.d.ts.map +1 -0
- package/dist/db/mysql/schema.js +61 -0
- package/dist/db/mysql/schema.js.map +1 -0
- package/dist/db/postgresql/index.d.ts +14 -0
- package/dist/db/postgresql/index.d.ts.map +1 -0
- package/dist/db/postgresql/index.js +246 -0
- package/dist/db/postgresql/index.js.map +1 -0
- package/dist/db/postgresql/schema.d.ts +591 -0
- package/dist/db/postgresql/schema.d.ts.map +1 -0
- package/dist/db/postgresql/schema.js +64 -0
- package/dist/db/postgresql/schema.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/relay/api-proxy.d.ts +10 -0
- package/dist/relay/api-proxy.d.ts.map +1 -0
- package/dist/relay/api-proxy.js +40 -0
- package/dist/relay/api-proxy.js.map +1 -0
- package/dist/runtime/cf/crypto.d.ts +7 -0
- package/dist/runtime/cf/crypto.d.ts.map +1 -0
- package/dist/runtime/cf/crypto.js +48 -0
- package/dist/runtime/cf/crypto.js.map +1 -0
- package/dist/runtime/cf/index.d.ts +20 -0
- package/dist/runtime/cf/index.d.ts.map +1 -0
- package/dist/runtime/cf/index.js +14 -0
- package/dist/runtime/cf/index.js.map +1 -0
- package/dist/runtime/cf/relay.d.ts +11 -0
- package/dist/runtime/cf/relay.d.ts.map +1 -0
- package/dist/runtime/cf/relay.js +57 -0
- package/dist/runtime/cf/relay.js.map +1 -0
- package/dist/runtime/cf/vault.d.ts +7 -0
- package/dist/runtime/cf/vault.d.ts.map +1 -0
- package/dist/runtime/cf/vault.js +68 -0
- package/dist/runtime/cf/vault.js.map +1 -0
- package/dist/runtime/node/crypto.d.ts +6 -0
- package/dist/runtime/node/crypto.d.ts.map +1 -0
- package/dist/runtime/node/crypto.js +26 -0
- package/dist/runtime/node/crypto.js.map +1 -0
- package/dist/runtime/node/index.d.ts +17 -0
- package/dist/runtime/node/index.d.ts.map +1 -0
- package/dist/runtime/node/index.js +14 -0
- package/dist/runtime/node/index.js.map +1 -0
- package/dist/runtime/node/relay.d.ts +6 -0
- package/dist/runtime/node/relay.d.ts.map +1 -0
- package/dist/runtime/node/relay.js +73 -0
- package/dist/runtime/node/relay.js.map +1 -0
- package/dist/runtime/node/vault.d.ts +7 -0
- package/dist/runtime/node/vault.d.ts.map +1 -0
- package/dist/runtime/node/vault.js +41 -0
- package/dist/runtime/node/vault.js.map +1 -0
- package/dist/slack/events.d.ts +15 -0
- package/dist/slack/events.d.ts.map +1 -0
- package/dist/slack/events.js +63 -0
- package/dist/slack/events.js.map +1 -0
- package/dist/slack/oauth.d.ts +13 -0
- package/dist/slack/oauth.d.ts.map +1 -0
- package/dist/slack/oauth.js +90 -0
- package/dist/slack/oauth.js.map +1 -0
- package/dist/slack/provisioner.d.ts +60 -0
- package/dist/slack/provisioner.d.ts.map +1 -0
- package/dist/slack/provisioner.js +156 -0
- package/dist/slack/provisioner.js.map +1 -0
- package/dist/types/crypto.d.ts +15 -0
- package/dist/types/crypto.d.ts.map +1 -0
- package/dist/types/crypto.js +2 -0
- package/dist/types/crypto.js.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/platform.d.ts +25 -0
- package/dist/types/platform.d.ts.map +1 -0
- package/dist/types/platform.js +2 -0
- package/dist/types/platform.js.map +1 -0
- package/dist/types/relay.d.ts +16 -0
- package/dist/types/relay.d.ts.map +1 -0
- package/dist/types/relay.js +2 -0
- package/dist/types/relay.js.map +1 -0
- package/dist/types/repository.d.ts +78 -0
- package/dist/types/repository.d.ts.map +1 -0
- package/dist/types/repository.js +6 -0
- package/dist/types/repository.js.map +1 -0
- package/dist/types/vault.d.ts +9 -0
- package/dist/types/vault.d.ts.map +1 -0
- package/dist/types/vault.js +2 -0
- package/dist/types/vault.js.map +1 -0
- package/dist/web/api.d.ts +9 -0
- package/dist/web/api.d.ts.map +1 -0
- package/dist/web/api.js +144 -0
- package/dist/web/api.js.map +1 -0
- package/dist/web/pages.d.ts +4 -0
- package/dist/web/pages.d.ts.map +1 -0
- package/dist/web/pages.js +401 -0
- package/dist/web/pages.js.map +1 -0
- package/dist/web/setup.d.ts +5 -0
- package/dist/web/setup.d.ts.map +1 -0
- package/dist/web/setup.js +208 -0
- package/dist/web/setup.js.map +1 -0
- package/package.json +46 -0
- package/src/app.ts +221 -0
- package/src/auth/handler.ts +343 -0
- package/src/auth/session.ts +89 -0
- package/src/db/d1/index.ts +304 -0
- package/src/db/d1/schema.ts +62 -0
- package/src/db/mysql/index.ts +301 -0
- package/src/db/mysql/schema.ts +78 -0
- package/src/db/postgresql/index.ts +311 -0
- package/src/db/postgresql/schema.ts +82 -0
- package/src/index.ts +21 -0
- package/src/relay/api-proxy.ts +61 -0
- package/src/runtime/cf/crypto.ts +74 -0
- package/src/runtime/cf/index.ts +31 -0
- package/src/runtime/cf/relay.ts +74 -0
- package/src/runtime/cf/vault.ts +99 -0
- package/src/runtime/node/crypto.ts +33 -0
- package/src/runtime/node/index.ts +28 -0
- package/src/runtime/node/relay.ts +98 -0
- package/src/runtime/node/vault.ts +50 -0
- package/src/slack/events.ts +92 -0
- package/src/slack/oauth.ts +127 -0
- package/src/slack/provisioner.ts +256 -0
- package/src/types/crypto.ts +14 -0
- package/src/types/index.ts +14 -0
- package/src/types/platform.ts +31 -0
- package/src/types/relay.ts +16 -0
- package/src/types/repository.ts +93 -0
- package/src/types/vault.ts +8 -0
- package/src/web/api.ts +204 -0
- package/src/web/pages.ts +458 -0
- package/src/web/setup.ts +270 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { Context, Next } from 'hono'
|
|
3
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
|
4
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
5
|
+
import type { OAuthStateRepository, WorkspaceAdminConfigRepository } from '../types/repository.js'
|
|
6
|
+
import type { CryptoProvider } from '../types/crypto.js'
|
|
7
|
+
import type { Vault } from '../types/vault.js'
|
|
8
|
+
import {
|
|
9
|
+
AUTH_SESSION_MAX_AGE_SECONDS,
|
|
10
|
+
createSessionCookieValue,
|
|
11
|
+
parseSessionCookieValue,
|
|
12
|
+
type AuthSession,
|
|
13
|
+
type AuthSessionUser,
|
|
14
|
+
} from './session.js'
|
|
15
|
+
|
|
16
|
+
const DEFAULT_WORKSPACE_ADMIN_CONFIG_ID = 'default'
|
|
17
|
+
const LOGIN_STATE_PREFIX = 'login:'
|
|
18
|
+
|
|
19
|
+
interface SlackTokenResponse {
|
|
20
|
+
ok: boolean
|
|
21
|
+
access_token?: string
|
|
22
|
+
error?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SlackUserInfoResponse {
|
|
26
|
+
ok: boolean
|
|
27
|
+
sub?: string
|
|
28
|
+
'https://slack.com/team_id'?: string
|
|
29
|
+
name?: string
|
|
30
|
+
email?: string
|
|
31
|
+
picture?: string
|
|
32
|
+
error?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AuthEnv {
|
|
36
|
+
Variables: {
|
|
37
|
+
user: AuthSessionUser
|
|
38
|
+
session: AuthSession
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createAuthHandler(
|
|
43
|
+
workspaceAdminConfig: WorkspaceAdminConfigRepository,
|
|
44
|
+
oauthStates: OAuthStateRepository,
|
|
45
|
+
crypto: CryptoProvider,
|
|
46
|
+
vault: Vault,
|
|
47
|
+
config: {
|
|
48
|
+
platformBaseUrl: string
|
|
49
|
+
},
|
|
50
|
+
) {
|
|
51
|
+
const app = new Hono<AuthEnv>()
|
|
52
|
+
|
|
53
|
+
app.get('/auth/login', async (c) => {
|
|
54
|
+
const creds = await getSlackLoginCredentials(workspaceAdminConfig, vault)
|
|
55
|
+
if (!creds) {
|
|
56
|
+
return c.redirect('/setup')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const state = await crypto.randomHex(16)
|
|
60
|
+
const nonce = await crypto.randomHex(8)
|
|
61
|
+
|
|
62
|
+
await oauthStates.deleteExpired()
|
|
63
|
+
await oauthStates.create({
|
|
64
|
+
state,
|
|
65
|
+
botId: `${LOGIN_STATE_PREFIX}${nonce}`,
|
|
66
|
+
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const params = new URLSearchParams({
|
|
70
|
+
response_type: 'code',
|
|
71
|
+
client_id: creds.clientId,
|
|
72
|
+
scope: 'openid profile email',
|
|
73
|
+
redirect_uri: `${config.platformBaseUrl}/auth/callback`,
|
|
74
|
+
state,
|
|
75
|
+
nonce,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return c.redirect(`https://slack.com/openid/connect/authorize?${params.toString()}`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
app.get('/auth/callback', async (c) => {
|
|
82
|
+
const error = c.req.query('error')
|
|
83
|
+
if (error) {
|
|
84
|
+
return renderErrorPage(c, 'Slack 로그인 오류', error, 400)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const code = c.req.query('code')
|
|
88
|
+
const state = c.req.query('state')
|
|
89
|
+
if (!code || !state) {
|
|
90
|
+
return renderErrorPage(c, '잘못된 요청', 'code/state가 누락됐어요.', 400)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await oauthStates.deleteExpired()
|
|
94
|
+
const storedState = await oauthStates.consume(state)
|
|
95
|
+
if (!storedState || !storedState.botId.startsWith(LOGIN_STATE_PREFIX)) {
|
|
96
|
+
return renderErrorPage(c, '잘못된 상태', '로그인 상태가 만료됐거나 유효하지 않아요.', 400)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const creds = await getSlackLoginCredentials(workspaceAdminConfig, vault)
|
|
100
|
+
if (!creds) {
|
|
101
|
+
return c.redirect('/setup')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const redirectUri = `${config.platformBaseUrl}/auth/callback`
|
|
105
|
+
const tokenRes = await fetch('https://slack.com/api/openid.connect.token', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
108
|
+
body: new URLSearchParams({
|
|
109
|
+
client_id: creds.clientId,
|
|
110
|
+
client_secret: creds.clientSecret,
|
|
111
|
+
code,
|
|
112
|
+
redirect_uri: redirectUri,
|
|
113
|
+
grant_type: 'authorization_code',
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
const tokenData = (await tokenRes.json()) as SlackTokenResponse
|
|
117
|
+
|
|
118
|
+
if (!tokenData.ok || !tokenData.access_token) {
|
|
119
|
+
return renderErrorPage(
|
|
120
|
+
c,
|
|
121
|
+
'Slack 토큰 교환 실패',
|
|
122
|
+
tokenData.error ?? 'unknown_error',
|
|
123
|
+
400,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const userInfoRes = await fetch(
|
|
128
|
+
'https://slack.com/api/openid.connect.userInfo',
|
|
129
|
+
{
|
|
130
|
+
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
const userInfo = (await userInfoRes.json()) as SlackUserInfoResponse
|
|
134
|
+
|
|
135
|
+
if (!userInfo.ok || !userInfo.sub) {
|
|
136
|
+
return renderErrorPage(
|
|
137
|
+
c,
|
|
138
|
+
'Slack 사용자 정보 조회 실패',
|
|
139
|
+
userInfo.error ?? 'unknown_error',
|
|
140
|
+
400,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const slackTeamId = userInfo['https://slack.com/team_id'] ?? ''
|
|
145
|
+
if (!slackTeamId) {
|
|
146
|
+
return renderErrorPage(c, 'Slack 팀 정보 누락', 'team_id를 확인할 수 없어요.', 400)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
|
|
150
|
+
const configuredTeamIds = getConfiguredTeamIds(configs)
|
|
151
|
+
if (
|
|
152
|
+
configuredTeamIds.length > 0 &&
|
|
153
|
+
!configuredTeamIds.includes(slackTeamId)
|
|
154
|
+
) {
|
|
155
|
+
return renderErrorPage(
|
|
156
|
+
c,
|
|
157
|
+
'허용되지 않은 워크스페이스',
|
|
158
|
+
`${slackTeamId} 워크스페이스는 이 플랫폼에 접근할 수 없어요.`,
|
|
159
|
+
403,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const defaultConfig = configs.find(
|
|
164
|
+
(cfg) => cfg.workspaceId === DEFAULT_WORKSPACE_ADMIN_CONFIG_ID,
|
|
165
|
+
)
|
|
166
|
+
const teamConfig = configs.find((cfg) => cfg.workspaceId === slackTeamId)
|
|
167
|
+
if (!teamConfig && defaultConfig) {
|
|
168
|
+
await workspaceAdminConfig.upsert({
|
|
169
|
+
workspaceId: slackTeamId,
|
|
170
|
+
slackClientId: defaultConfig.slackClientId,
|
|
171
|
+
slackClientSecretEnc: defaultConfig.slackClientSecretEnc,
|
|
172
|
+
dCookieEnc: defaultConfig.dCookieEnc,
|
|
173
|
+
xoxcTokenEnc: defaultConfig.xoxcTokenEnc,
|
|
174
|
+
workspaceDomain: defaultConfig.workspaceDomain,
|
|
175
|
+
updatedByUserId: defaultConfig.updatedByUserId,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const user: AuthSessionUser = {
|
|
180
|
+
slackUserId: userInfo.sub,
|
|
181
|
+
slackTeamId,
|
|
182
|
+
name: userInfo.name ?? 'Slack User',
|
|
183
|
+
email: userInfo.email ?? null,
|
|
184
|
+
avatarUrl: userInfo.picture ?? null,
|
|
185
|
+
}
|
|
186
|
+
const expiresAt = new Date(
|
|
187
|
+
Date.now() + AUTH_SESSION_MAX_AGE_SECONDS * 1000,
|
|
188
|
+
)
|
|
189
|
+
const sessionCookie = await createSessionCookieValue(vault, user, expiresAt)
|
|
190
|
+
|
|
191
|
+
setCookie(c, 'sena_session', sessionCookie, {
|
|
192
|
+
httpOnly: true,
|
|
193
|
+
secure: true,
|
|
194
|
+
sameSite: 'Lax',
|
|
195
|
+
path: '/',
|
|
196
|
+
maxAge: AUTH_SESSION_MAX_AGE_SECONDS,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return c.redirect('/')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
app.post('/auth/logout', (c) => {
|
|
203
|
+
deleteCookie(c, 'sena_session', { path: '/' })
|
|
204
|
+
return c.redirect('/auth/login')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return app
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function createAuthMiddleware(
|
|
211
|
+
workspaceAdminConfig: WorkspaceAdminConfigRepository,
|
|
212
|
+
vault: Vault,
|
|
213
|
+
) {
|
|
214
|
+
const requireAuth = async (c: Context, next: Next) => {
|
|
215
|
+
const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
|
|
216
|
+
if (configs.length === 0) {
|
|
217
|
+
return unauthenticated(c, '/setup', 'slack_login_not_configured', 503)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const rawSession = getCookie(c, 'sena_session')
|
|
221
|
+
if (!rawSession) {
|
|
222
|
+
return unauthenticated(c, '/auth/login', 'unauthorized', 401)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const session = await parseSessionCookieValue(vault, rawSession)
|
|
226
|
+
if (!session) {
|
|
227
|
+
deleteCookie(c, 'sena_session', { path: '/' })
|
|
228
|
+
return unauthenticated(c, '/auth/login', 'unauthorized', 401)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const configuredTeamIds = getConfiguredTeamIds(configs)
|
|
232
|
+
if (
|
|
233
|
+
configuredTeamIds.length > 0 &&
|
|
234
|
+
!configuredTeamIds.includes(session.user.slackTeamId)
|
|
235
|
+
) {
|
|
236
|
+
deleteCookie(c, 'sena_session', { path: '/' })
|
|
237
|
+
return unauthenticated(c, '/auth/login', 'workspace_forbidden', 403)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
c.set('user', session.user)
|
|
241
|
+
c.set('session', session)
|
|
242
|
+
await next()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { requireAuth }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function getSlackLoginCredentials(
|
|
249
|
+
workspaceAdminConfig: WorkspaceAdminConfigRepository,
|
|
250
|
+
vault: Vault,
|
|
251
|
+
): Promise<{ clientId: string; clientSecret: string } | null> {
|
|
252
|
+
const configs = await listConfiguredSlackLoginConfigs(workspaceAdminConfig)
|
|
253
|
+
const preferredConfig =
|
|
254
|
+
configs.find((cfg) => cfg.workspaceId !== DEFAULT_WORKSPACE_ADMIN_CONFIG_ID) ??
|
|
255
|
+
configs[0]
|
|
256
|
+
|
|
257
|
+
if (!preferredConfig?.slackClientId || !preferredConfig.slackClientSecretEnc) {
|
|
258
|
+
return null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
clientId: preferredConfig.slackClientId,
|
|
263
|
+
clientSecret: await vault.decrypt(preferredConfig.slackClientSecretEnc),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function listConfiguredSlackLoginConfigs(
|
|
268
|
+
workspaceAdminConfig: WorkspaceAdminConfigRepository,
|
|
269
|
+
) {
|
|
270
|
+
const configs = await workspaceAdminConfig.findAll()
|
|
271
|
+
return configs.filter(
|
|
272
|
+
(config) =>
|
|
273
|
+
typeof config.slackClientId === 'string' &&
|
|
274
|
+
config.slackClientId.trim().length > 0 &&
|
|
275
|
+
typeof config.slackClientSecretEnc === 'string' &&
|
|
276
|
+
config.slackClientSecretEnc.length > 0,
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getConfiguredTeamIds(configs: Array<{ workspaceId: string }>): string[] {
|
|
281
|
+
return configs
|
|
282
|
+
.map((config) => config.workspaceId)
|
|
283
|
+
.filter((workspaceId) => workspaceId !== DEFAULT_WORKSPACE_ADMIN_CONFIG_ID)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function unauthenticated(
|
|
287
|
+
c: Context,
|
|
288
|
+
redirectPath: string,
|
|
289
|
+
error: string,
|
|
290
|
+
status: ContentfulStatusCode,
|
|
291
|
+
): Response | Promise<Response> {
|
|
292
|
+
if (c.req.path.startsWith('/api/')) {
|
|
293
|
+
return c.json({ error, redirectTo: redirectPath }, { status })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return c.redirect(redirectPath)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renderErrorPage(
|
|
300
|
+
c: Context,
|
|
301
|
+
title: string,
|
|
302
|
+
message: string,
|
|
303
|
+
status: ContentfulStatusCode,
|
|
304
|
+
): Response {
|
|
305
|
+
return c.html(
|
|
306
|
+
`<!DOCTYPE html>
|
|
307
|
+
<html lang="ko">
|
|
308
|
+
<head>
|
|
309
|
+
<meta charset="UTF-8">
|
|
310
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
311
|
+
<title>${escapeHtml(title)} - Sena Platform</title>
|
|
312
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
313
|
+
</head>
|
|
314
|
+
<body class="bg-gray-50 min-h-screen flex items-center justify-center px-4">
|
|
315
|
+
<main class="w-full max-w-md bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
|
316
|
+
<h1 class="text-xl font-bold text-gray-900 mb-2">${escapeHtml(title)}</h1>
|
|
317
|
+
<p class="text-sm text-gray-600 mb-6">${escapeHtml(message)}</p>
|
|
318
|
+
<a href="/auth/login" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 transition-colors">다시 로그인</a>
|
|
319
|
+
</main>
|
|
320
|
+
</body>
|
|
321
|
+
</html>`,
|
|
322
|
+
{ status },
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function escapeHtml(value: string): string {
|
|
327
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
328
|
+
switch (char) {
|
|
329
|
+
case '&':
|
|
330
|
+
return '&'
|
|
331
|
+
case '<':
|
|
332
|
+
return '<'
|
|
333
|
+
case '>':
|
|
334
|
+
return '>'
|
|
335
|
+
case '"':
|
|
336
|
+
return '"'
|
|
337
|
+
case "'":
|
|
338
|
+
return '''
|
|
339
|
+
default:
|
|
340
|
+
return char
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Vault } from '../types/vault.js'
|
|
2
|
+
|
|
3
|
+
export interface AuthSessionUser {
|
|
4
|
+
slackUserId: string
|
|
5
|
+
slackTeamId: string
|
|
6
|
+
name: string
|
|
7
|
+
email: string | null
|
|
8
|
+
avatarUrl: string | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AuthSession {
|
|
12
|
+
user: AuthSessionUser
|
|
13
|
+
expiresAt: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const AUTH_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
|
17
|
+
|
|
18
|
+
export async function createSessionCookieValue(
|
|
19
|
+
vault: Vault,
|
|
20
|
+
user: AuthSessionUser,
|
|
21
|
+
expiresAt: Date,
|
|
22
|
+
): Promise<string> {
|
|
23
|
+
const encrypted = await vault.encrypt(
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
user,
|
|
26
|
+
expiresAt: expiresAt.toISOString(),
|
|
27
|
+
} satisfies AuthSession),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return toBase64Url(encrypted)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function parseSessionCookieValue(
|
|
34
|
+
vault: Vault,
|
|
35
|
+
rawCookieValue: string,
|
|
36
|
+
): Promise<AuthSession | null> {
|
|
37
|
+
try {
|
|
38
|
+
const decrypted = await vault.decrypt(fromBase64Url(rawCookieValue))
|
|
39
|
+
const parsed: unknown = JSON.parse(decrypted)
|
|
40
|
+
|
|
41
|
+
if (!isAuthSession(parsed)) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const expiresAt = new Date(parsed.expiresAt)
|
|
46
|
+
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
user: parsed.user,
|
|
52
|
+
expiresAt: expiresAt.toISOString(),
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return typeof value === 'object' && value !== null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isAuthSessionUser(value: unknown): value is AuthSessionUser {
|
|
64
|
+
if (!isRecord(value)) return false
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
typeof value.slackUserId === 'string' &&
|
|
68
|
+
typeof value.slackTeamId === 'string' &&
|
|
69
|
+
typeof value.name === 'string' &&
|
|
70
|
+
(value.email === null || typeof value.email === 'string') &&
|
|
71
|
+
(value.avatarUrl === null || typeof value.avatarUrl === 'string')
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isAuthSession(value: unknown): value is AuthSession {
|
|
76
|
+
if (!isRecord(value)) return false
|
|
77
|
+
|
|
78
|
+
return typeof value.expiresAt === 'string' && isAuthSessionUser(value.user)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toBase64Url(base64: string): string {
|
|
82
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fromBase64Url(base64Url: string): string {
|
|
86
|
+
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
|
87
|
+
const paddingLength = (4 - (base64.length % 4 || 4)) % 4
|
|
88
|
+
return base64 + '='.repeat(paddingLength)
|
|
89
|
+
}
|