@insureco/cli 0.1.10 → 0.1.12

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.
Files changed (59) hide show
  1. package/dist/commands/deploy.d.ts.map +1 -1
  2. package/dist/commands/deploy.js +6 -52
  3. package/dist/commands/deploy.js.map +1 -1
  4. package/dist/commands/push.d.ts.map +1 -1
  5. package/dist/commands/push.js +40 -57
  6. package/dist/commands/push.js.map +1 -1
  7. package/dist/commands/rollback.d.ts.map +1 -1
  8. package/dist/commands/rollback.js +10 -35
  9. package/dist/commands/rollback.js.map +1 -1
  10. package/dist/commands/sample.d.ts +2 -0
  11. package/dist/commands/sample.d.ts.map +1 -1
  12. package/dist/commands/sample.js +140 -12
  13. package/dist/commands/sample.js.map +1 -1
  14. package/dist/commands/versions.d.ts.map +1 -1
  15. package/dist/commands/versions.js +10 -56
  16. package/dist/commands/versions.js.map +1 -1
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/lib/builder.d.ts.map +1 -1
  20. package/dist/lib/builder.js +58 -12
  21. package/dist/lib/builder.js.map +1 -1
  22. package/dist/lib/watch.d.ts +26 -0
  23. package/dist/lib/watch.d.ts.map +1 -0
  24. package/dist/lib/watch.js +136 -0
  25. package/dist/lib/watch.js.map +1 -0
  26. package/dist/templates/nextjs-oauth/.dockerignore +6 -0
  27. package/dist/templates/nextjs-oauth/.env.example +12 -0
  28. package/dist/templates/nextjs-oauth/Dockerfile +51 -0
  29. package/dist/templates/nextjs-oauth/README.md +115 -0
  30. package/dist/templates/nextjs-oauth/catalog-info.yaml +17 -0
  31. package/dist/templates/nextjs-oauth/helm/Chart.yaml +6 -0
  32. package/dist/templates/nextjs-oauth/helm/templates/_helpers.tpl +49 -0
  33. package/dist/templates/nextjs-oauth/helm/templates/configmap.yaml +10 -0
  34. package/dist/templates/nextjs-oauth/helm/templates/deployment.yaml +56 -0
  35. package/dist/templates/nextjs-oauth/helm/templates/ingress.yaml +41 -0
  36. package/dist/templates/nextjs-oauth/helm/templates/service.yaml +15 -0
  37. package/dist/templates/nextjs-oauth/helm/values.yaml +69 -0
  38. package/dist/templates/nextjs-oauth/helm/{{name}}/Chart.yaml +9 -0
  39. package/dist/templates/nextjs-oauth/helm/{{name}}/templates/deployment.yaml +68 -0
  40. package/dist/templates/nextjs-oauth/helm/{{name}}/templates/service.yaml +17 -0
  41. package/dist/templates/nextjs-oauth/helm/{{name}}/values.yaml +51 -0
  42. package/dist/templates/nextjs-oauth/next.config.js +23 -0
  43. package/dist/templates/nextjs-oauth/package.json +30 -0
  44. package/dist/templates/nextjs-oauth/public/.gitkeep +0 -0
  45. package/dist/templates/nextjs-oauth/src/app/api/auth/callback/route.ts +77 -0
  46. package/dist/templates/nextjs-oauth/src/app/api/auth/login/route.ts +16 -0
  47. package/dist/templates/nextjs-oauth/src/app/api/auth/logout/route.ts +7 -0
  48. package/dist/templates/nextjs-oauth/src/app/api/auth/session/route.ts +42 -0
  49. package/dist/templates/nextjs-oauth/src/app/api/example/route.ts +63 -0
  50. package/dist/templates/nextjs-oauth/src/app/api/health/route.ts +10 -0
  51. package/dist/templates/nextjs-oauth/src/app/dashboard/page.tsx +92 -0
  52. package/dist/templates/nextjs-oauth/src/app/layout.tsx +18 -0
  53. package/dist/templates/nextjs-oauth/src/app/page.tsx +110 -0
  54. package/dist/templates/nextjs-oauth/src/lib/auth.ts +285 -0
  55. package/dist/templates/nextjs-oauth/src/middleware.ts +71 -0
  56. package/dist/templates/nextjs-oauth/tsconfig.json +26 -0
  57. package/dist/types/index.d.ts +9 -5
  58. package/dist/types/index.d.ts.map +1 -1
  59. package/package.json +1 -1
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { clearAuthCookies, getAppUrl } from '@/lib/auth'
3
+
4
+ export async function POST() {
5
+ await clearAuthCookies()
6
+ return NextResponse.redirect(new URL('/', getAppUrl()), { status: 303 })
7
+ }
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { cookies } from 'next/headers'
3
+ import {
4
+ getCurrentUser,
5
+ refreshAccessToken,
6
+ fetchUserInfo,
7
+ createSessionToken,
8
+ setSessionCookies,
9
+ COOKIE_PREFIX,
10
+ } from '@/lib/auth'
11
+
12
+ const NO_CACHE_HEADERS = { 'Cache-Control': 'no-store, no-cache, must-revalidate' }
13
+
14
+ export async function GET() {
15
+ try {
16
+ const user = await getCurrentUser()
17
+
18
+ if (user) {
19
+ return NextResponse.json({ user }, { headers: NO_CACHE_HEADERS })
20
+ }
21
+
22
+ // Session expired — try refreshing via bio-id refresh token
23
+ const cookieStore = await cookies()
24
+ const refreshToken = cookieStore.get(`${COOKIE_PREFIX}_refresh_token`)?.value
25
+
26
+ if (refreshToken) {
27
+ try {
28
+ const tokens = await refreshAccessToken(refreshToken)
29
+ const refreshedUser = await fetchUserInfo(tokens.access_token)
30
+ const sessionToken = await createSessionToken(refreshedUser)
31
+ await setSessionCookies(sessionToken, tokens.refresh_token)
32
+ return NextResponse.json({ user: refreshedUser }, { headers: NO_CACHE_HEADERS })
33
+ } catch {
34
+ // Refresh token also invalid — user must re-login
35
+ }
36
+ }
37
+
38
+ return NextResponse.json({ user: null }, { headers: NO_CACHE_HEADERS })
39
+ } catch {
40
+ return NextResponse.json({ user: null }, { headers: NO_CACHE_HEADERS })
41
+ }
42
+ }
@@ -0,0 +1,63 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+
3
+ // In-memory storage for demo
4
+ const items: Map<string, { id: string; name: string; createdAt: string }> = new Map()
5
+
6
+ export async function GET() {
7
+ const itemList = Array.from(items.values())
8
+ return NextResponse.json({
9
+ success: true,
10
+ data: itemList,
11
+ meta: {
12
+ total: itemList.length,
13
+ timestamp: new Date().toISOString(),
14
+ },
15
+ })
16
+ }
17
+
18
+ export async function POST(request: NextRequest) {
19
+ try {
20
+ const body = await request.json()
21
+
22
+ if (!body.name || typeof body.name !== 'string') {
23
+ return NextResponse.json(
24
+ {
25
+ success: false,
26
+ error: {
27
+ code: 'VALIDATION_ERROR',
28
+ message: 'Name is required',
29
+ },
30
+ },
31
+ { status: 400 }
32
+ )
33
+ }
34
+
35
+ const id = Date.now().toString(36) + Math.random().toString(36).substring(2)
36
+ const item = {
37
+ id,
38
+ name: body.name,
39
+ createdAt: new Date().toISOString(),
40
+ }
41
+
42
+ items.set(id, item)
43
+
44
+ return NextResponse.json(
45
+ {
46
+ success: true,
47
+ data: item,
48
+ },
49
+ { status: 201 }
50
+ )
51
+ } catch {
52
+ return NextResponse.json(
53
+ {
54
+ success: false,
55
+ error: {
56
+ code: 'PARSE_ERROR',
57
+ message: 'Invalid JSON body',
58
+ },
59
+ },
60
+ { status: 400 }
61
+ )
62
+ }
63
+ }
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ export async function GET() {
4
+ return NextResponse.json({
5
+ status: 'healthy',
6
+ service: '{{name}}',
7
+ timestamp: new Date().toISOString(),
8
+ environment: process.env.NODE_ENV,
9
+ })
10
+ }
@@ -0,0 +1,92 @@
1
+ import { redirect } from 'next/navigation'
2
+ import { getCurrentUser } from '@/lib/auth'
3
+
4
+ export default async function DashboardPage() {
5
+ const user = await getCurrentUser()
6
+
7
+ if (!user) {
8
+ redirect('/api/auth/login?returnTo=/dashboard')
9
+ }
10
+
11
+ return (
12
+ <main style={{
13
+ maxWidth: '48rem',
14
+ margin: '0 auto',
15
+ padding: '2rem',
16
+ fontFamily: 'system-ui, -apple-system, sans-serif',
17
+ }}>
18
+ <div style={{
19
+ display: 'flex',
20
+ justifyContent: 'space-between',
21
+ alignItems: 'center',
22
+ marginBottom: '2rem',
23
+ }}>
24
+ <h1 style={{ fontSize: '2rem', margin: 0 }}>Dashboard</h1>
25
+ <form action="/api/auth/logout" method="post">
26
+ <button
27
+ type="submit"
28
+ style={{
29
+ padding: '0.5rem 1rem',
30
+ backgroundColor: '#dc2626',
31
+ color: 'white',
32
+ borderRadius: '0.375rem',
33
+ border: 'none',
34
+ cursor: 'pointer',
35
+ fontSize: '0.875rem',
36
+ }}
37
+ >
38
+ Logout
39
+ </button>
40
+ </form>
41
+ </div>
42
+
43
+ <div style={{
44
+ backgroundColor: '#f9fafb',
45
+ border: '1px solid #e5e7eb',
46
+ borderRadius: '0.5rem',
47
+ padding: '1.5rem',
48
+ marginBottom: '1.5rem',
49
+ }}>
50
+ <h2 style={{ fontSize: '1.25rem', marginTop: 0, marginBottom: '1rem' }}>
51
+ Welcome, {user.name}
52
+ </h2>
53
+ <dl style={{ margin: 0 }}>
54
+ <dt style={{ fontWeight: 600, color: '#6b7280', fontSize: '0.875rem' }}>Email</dt>
55
+ <dd style={{ margin: '0 0 0.75rem 0' }}>{user.email}</dd>
56
+
57
+ <dt style={{ fontWeight: 600, color: '#6b7280', fontSize: '0.875rem' }}>User ID</dt>
58
+ <dd style={{ margin: '0 0 0.75rem 0', fontFamily: 'monospace', fontSize: '0.875rem' }}>{user.id}</dd>
59
+
60
+ <dt style={{ fontWeight: 600, color: '#6b7280', fontSize: '0.875rem' }}>Roles</dt>
61
+ <dd style={{ margin: 0 }}>
62
+ {user.roles.length > 0 ? (
63
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
64
+ {user.roles.map((role) => (
65
+ <span
66
+ key={role}
67
+ style={{
68
+ backgroundColor: '#dbeafe',
69
+ color: '#1e40af',
70
+ padding: '0.125rem 0.5rem',
71
+ borderRadius: '9999px',
72
+ fontSize: '0.75rem',
73
+ }}
74
+ >
75
+ {role}
76
+ </span>
77
+ ))}
78
+ </div>
79
+ ) : (
80
+ <span style={{ color: '#9ca3af' }}>No roles assigned</span>
81
+ )}
82
+ </dd>
83
+ </dl>
84
+ </div>
85
+
86
+ <p style={{ color: '#6b7280', fontSize: '0.875rem' }}>
87
+ This page is protected by middleware and server-side auth checks.
88
+ Edit <code>src/app/dashboard/page.tsx</code> to customize.
89
+ </p>
90
+ </main>
91
+ )
92
+ }
@@ -0,0 +1,18 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ export const metadata: Metadata = {
4
+ title: '{{name}}',
5
+ description: '{{description}}',
6
+ }
7
+
8
+ export default function RootLayout({
9
+ children,
10
+ }: {
11
+ children: React.ReactNode
12
+ }) {
13
+ return (
14
+ <html lang="en">
15
+ <body>{children}</body>
16
+ </html>
17
+ )
18
+ }
@@ -0,0 +1,110 @@
1
+ import { getCurrentUser } from '@/lib/auth'
2
+
3
+ export default async function Home() {
4
+ const user = await getCurrentUser()
5
+
6
+ return (
7
+ <main style={{
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ alignItems: 'center',
11
+ justifyContent: 'center',
12
+ minHeight: '100vh',
13
+ padding: '2rem',
14
+ fontFamily: 'system-ui, -apple-system, sans-serif',
15
+ }}>
16
+ <h1 style={{ fontSize: '3rem', marginBottom: '1rem' }}>
17
+ {'{{name}}'}
18
+ </h1>
19
+ <p style={{ fontSize: '1.25rem', color: '#666', marginBottom: '2rem' }}>
20
+ {'{{description}}'}
21
+ </p>
22
+
23
+ {user ? (
24
+ <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
25
+ <p style={{ marginBottom: '1rem' }}>
26
+ Signed in as <strong>{user.name}</strong> ({user.email})
27
+ </p>
28
+ <div style={{ display: 'flex', gap: '1rem' }}>
29
+ <a
30
+ href="/dashboard"
31
+ style={{
32
+ padding: '0.75rem 1.5rem',
33
+ backgroundColor: '#0070f3',
34
+ color: 'white',
35
+ borderRadius: '0.5rem',
36
+ textDecoration: 'none',
37
+ }}
38
+ >
39
+ Dashboard
40
+ </a>
41
+ <form action="/api/auth/logout" method="post" style={{ display: 'inline' }}>
42
+ <button
43
+ type="submit"
44
+ style={{
45
+ padding: '0.75rem 1.5rem',
46
+ backgroundColor: '#dc2626',
47
+ color: 'white',
48
+ borderRadius: '0.5rem',
49
+ border: 'none',
50
+ cursor: 'pointer',
51
+ fontSize: 'inherit',
52
+ }}
53
+ >
54
+ Logout
55
+ </button>
56
+ </form>
57
+ </div>
58
+ </div>
59
+ ) : (
60
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem' }}>
61
+ <a
62
+ href="/api/auth/login"
63
+ style={{
64
+ padding: '0.75rem 1.5rem',
65
+ backgroundColor: '#0070f3',
66
+ color: 'white',
67
+ borderRadius: '0.5rem',
68
+ textDecoration: 'none',
69
+ }}
70
+ >
71
+ Sign In
72
+ </a>
73
+ </div>
74
+ )}
75
+
76
+ <div style={{ display: 'flex', gap: '1rem' }}>
77
+ <a
78
+ href="/api/health"
79
+ style={{
80
+ padding: '0.5rem 1rem',
81
+ border: '1px solid #e5e7eb',
82
+ borderRadius: '0.5rem',
83
+ textDecoration: 'none',
84
+ color: '#666',
85
+ fontSize: '0.875rem',
86
+ }}
87
+ >
88
+ Health Check
89
+ </a>
90
+ <a
91
+ href="/api/example"
92
+ style={{
93
+ padding: '0.5rem 1rem',
94
+ border: '1px solid #e5e7eb',
95
+ borderRadius: '0.5rem',
96
+ textDecoration: 'none',
97
+ color: '#666',
98
+ fontSize: '0.875rem',
99
+ }}
100
+ >
101
+ Example API
102
+ </a>
103
+ </div>
104
+
105
+ <footer style={{ marginTop: '4rem', color: '#999' }}>
106
+ Deployed on Tawa Platform
107
+ </footer>
108
+ </main>
109
+ )
110
+ }
@@ -0,0 +1,285 @@
1
+ import { cookies } from 'next/headers'
2
+ import { SignJWT, jwtVerify } from 'jose'
3
+ import crypto from 'crypto'
4
+
5
+ // Configuration — read lazily so `next build` doesn't fail when env vars are
6
+ // absent (they are injected at runtime via ConfigMap / Secret in K8s).
7
+ function getBioIdUrl(): string {
8
+ return process.env.BIO_ID_URL || 'http://localhost:6100'
9
+ }
10
+ function getAppUrl(): string {
11
+ return process.env.APP_URL || 'http://localhost:3000'
12
+ }
13
+ function getClientId(): string {
14
+ const id = process.env.OAUTH_CLIENT_ID
15
+ if (!id) throw new Error('OAUTH_CLIENT_ID is not configured')
16
+ return id
17
+ }
18
+ function getClientSecret(): string {
19
+ const secret = process.env.OAUTH_CLIENT_SECRET
20
+ if (!secret) throw new Error('OAUTH_CLIENT_SECRET is not configured')
21
+ return secret
22
+ }
23
+ function getJwtSecret(): Uint8Array {
24
+ const secret = process.env.JWT_SECRET
25
+ if (!secret && process.env.NODE_ENV === 'production') {
26
+ throw new Error('JWT_SECRET environment variable is required in production')
27
+ }
28
+ return new TextEncoder().encode(secret || 'dev-only-secret-do-not-use-in-production')
29
+ }
30
+
31
+ const COOKIE_PREFIX = '{{name}}'
32
+ const SESSION_MAX_AGE = 60 * 60 // 1 hour
33
+ const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
34
+ const OAUTH_FLOW_MAX_AGE = 60 * 10 // 10 minutes
35
+
36
+ export interface User {
37
+ id: string
38
+ email: string
39
+ name: string
40
+ roles: string[]
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export function sanitizeReturnTo(returnTo: string | undefined, fallback = '/dashboard'): string {
48
+ if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) {
49
+ return fallback
50
+ }
51
+ return returnTo
52
+ }
53
+
54
+ export function generatePKCE(): { codeVerifier: string; codeChallenge: string } {
55
+ const codeVerifier = crypto.randomBytes(32).toString('base64url')
56
+ const codeChallenge = crypto
57
+ .createHash('sha256')
58
+ .update(codeVerifier)
59
+ .digest('base64url')
60
+ return { codeVerifier, codeChallenge }
61
+ }
62
+
63
+ export function getAuthorizationUrl(state: string, codeChallenge: string): string {
64
+ const params = new URLSearchParams({
65
+ client_id: getClientId(),
66
+ redirect_uri: `${getAppUrl()}/api/auth/callback`,
67
+ response_type: 'code',
68
+ scope: 'openid profile email',
69
+ state,
70
+ code_challenge: codeChallenge,
71
+ code_challenge_method: 'S256',
72
+ })
73
+ return `${getBioIdUrl()}/oauth/authorize?${params.toString()}`
74
+ }
75
+
76
+ async function parseErrorResponse(response: Response, fallbackMessage: string): Promise<string> {
77
+ try {
78
+ const error = await response.json()
79
+ return error.error_description || error.error || fallbackMessage
80
+ } catch {
81
+ return fallbackMessage
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Bio-id API calls
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export async function exchangeCodeForTokens(
90
+ code: string,
91
+ codeVerifier: string
92
+ ): Promise<{
93
+ access_token: string
94
+ token_type: string
95
+ expires_in: number
96
+ refresh_token: string
97
+ scope: string
98
+ id_token?: string
99
+ }> {
100
+ const response = await fetch(`${getBioIdUrl()}/api/oauth/token`, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
103
+ body: new URLSearchParams({
104
+ grant_type: 'authorization_code',
105
+ code,
106
+ redirect_uri: `${getAppUrl()}/api/auth/callback`,
107
+ client_id: getClientId(),
108
+ client_secret: getClientSecret(),
109
+ code_verifier: codeVerifier,
110
+ }).toString(),
111
+ })
112
+
113
+ if (!response.ok) {
114
+ throw new Error(await parseErrorResponse(response, 'Token exchange failed'))
115
+ }
116
+
117
+ return response.json()
118
+ }
119
+
120
+ export async function refreshAccessToken(refreshToken: string): Promise<{
121
+ access_token: string
122
+ token_type: string
123
+ expires_in: number
124
+ refresh_token: string
125
+ scope: string
126
+ }> {
127
+ const response = await fetch(`${getBioIdUrl()}/api/oauth/token`, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
130
+ body: new URLSearchParams({
131
+ grant_type: 'refresh_token',
132
+ refresh_token: refreshToken,
133
+ client_id: getClientId(),
134
+ client_secret: getClientSecret(),
135
+ }).toString(),
136
+ })
137
+
138
+ if (!response.ok) {
139
+ throw new Error(await parseErrorResponse(response, 'Token refresh failed'))
140
+ }
141
+
142
+ return response.json()
143
+ }
144
+
145
+ export async function fetchUserInfo(accessToken: string): Promise<User> {
146
+ const response = await fetch(`${getBioIdUrl()}/api/oauth/userinfo`, {
147
+ headers: { Authorization: `Bearer ${accessToken}` },
148
+ })
149
+
150
+ if (!response.ok) {
151
+ throw new Error('Failed to fetch user info')
152
+ }
153
+
154
+ const data = await response.json()
155
+
156
+ return {
157
+ id: data.bio_id || data.sub,
158
+ email: data.email,
159
+ name: data.name,
160
+ roles: data.roles || [],
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Session management
166
+ //
167
+ // After the OAuth callback, we create OUR OWN session JWT signed with
168
+ // JWT_SECRET. This avoids needing to verify bio-id's access token locally
169
+ // (bio-id uses HS256 signed with its own server secret).
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export async function createSessionToken(user: User): Promise<string> {
173
+ return new SignJWT({
174
+ sub: user.id,
175
+ email: user.email,
176
+ name: user.name,
177
+ roles: user.roles,
178
+ })
179
+ .setProtectedHeader({ alg: 'HS256' })
180
+ .setIssuedAt()
181
+ .setExpirationTime(`${SESSION_MAX_AGE}s`)
182
+ .setIssuer(getAppUrl())
183
+ .sign(getJwtSecret())
184
+ }
185
+
186
+ export async function setSessionCookies(
187
+ sessionToken: string,
188
+ bioRefreshToken: string
189
+ ): Promise<void> {
190
+ const cookieStore = await cookies()
191
+ const secure = getAppUrl().startsWith('https://')
192
+
193
+ cookieStore.set(`${COOKIE_PREFIX}_session`, sessionToken, {
194
+ httpOnly: true,
195
+ secure,
196
+ sameSite: 'lax',
197
+ maxAge: SESSION_MAX_AGE,
198
+ path: '/',
199
+ })
200
+
201
+ cookieStore.set(`${COOKIE_PREFIX}_refresh_token`, bioRefreshToken, {
202
+ httpOnly: true,
203
+ secure,
204
+ sameSite: 'lax',
205
+ maxAge: REFRESH_TOKEN_MAX_AGE,
206
+ path: '/',
207
+ })
208
+ }
209
+
210
+ export async function clearAuthCookies(): Promise<void> {
211
+ const cookieStore = await cookies()
212
+ cookieStore.delete(`${COOKIE_PREFIX}_session`)
213
+ cookieStore.delete(`${COOKIE_PREFIX}_refresh_token`)
214
+ cookieStore.delete('oauth_state')
215
+ cookieStore.delete('oauth_code_verifier')
216
+ cookieStore.delete('oauth_return_to')
217
+ }
218
+
219
+ /**
220
+ * Get the current user from the session cookie (no network call).
221
+ * Verifies OUR session JWT — not bio-id's access token.
222
+ */
223
+ export async function getCurrentUser(): Promise<User | null> {
224
+ const cookieStore = await cookies()
225
+ const sessionToken = cookieStore.get(`${COOKIE_PREFIX}_session`)?.value
226
+
227
+ if (!sessionToken) {
228
+ return null
229
+ }
230
+
231
+ try {
232
+ const { payload } = await jwtVerify(sessionToken, getJwtSecret(), {
233
+ issuer: getAppUrl(),
234
+ })
235
+
236
+ return {
237
+ id: payload.sub || '',
238
+ email: payload.email as string,
239
+ name: payload.name as string,
240
+ roles: (payload.roles as string[]) || [],
241
+ }
242
+ } catch {
243
+ return null
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Generate a login URL with PKCE and state stored in cookies
249
+ */
250
+ export async function getLoginUrl(returnTo?: string): Promise<string> {
251
+ const cookieStore = await cookies()
252
+ const secure = getAppUrl().startsWith('https://')
253
+
254
+ const state = crypto.randomBytes(16).toString('hex')
255
+ const { codeVerifier, codeChallenge } = generatePKCE()
256
+
257
+ cookieStore.set('oauth_state', state, {
258
+ httpOnly: true,
259
+ secure,
260
+ sameSite: 'lax',
261
+ maxAge: OAUTH_FLOW_MAX_AGE,
262
+ path: '/',
263
+ })
264
+
265
+ cookieStore.set('oauth_code_verifier', codeVerifier, {
266
+ httpOnly: true,
267
+ secure,
268
+ sameSite: 'lax',
269
+ maxAge: OAUTH_FLOW_MAX_AGE,
270
+ path: '/',
271
+ })
272
+
273
+ const safeReturnTo = sanitizeReturnTo(returnTo)
274
+ cookieStore.set('oauth_return_to', safeReturnTo, {
275
+ httpOnly: true,
276
+ secure,
277
+ sameSite: 'lax',
278
+ maxAge: OAUTH_FLOW_MAX_AGE,
279
+ path: '/',
280
+ })
281
+
282
+ return getAuthorizationUrl(state, codeChallenge)
283
+ }
284
+
285
+ export { COOKIE_PREFIX, getBioIdUrl, getAppUrl }
@@ -0,0 +1,71 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { jwtVerify } from 'jose'
3
+
4
+ const COOKIE_PREFIX = '{{name}}'
5
+
6
+ // Routes that require authentication
7
+ const PROTECTED_PATHS = ['/dashboard']
8
+
9
+ // Routes that should never be blocked
10
+ const PUBLIC_PATHS = [
11
+ '/',
12
+ '/api/auth',
13
+ '/api/health',
14
+ ]
15
+
16
+ function isPublicPath(pathname: string): boolean {
17
+ return PUBLIC_PATHS.some(
18
+ (path) => pathname === path || pathname.startsWith(`${path}/`)
19
+ )
20
+ }
21
+
22
+ function isProtectedPath(pathname: string): boolean {
23
+ return PROTECTED_PATHS.some(
24
+ (path) => pathname === path || pathname.startsWith(`${path}/`)
25
+ )
26
+ }
27
+
28
+ export async function middleware(request: NextRequest) {
29
+ const { pathname } = request.nextUrl
30
+
31
+ if (isPublicPath(pathname)) {
32
+ return NextResponse.next()
33
+ }
34
+
35
+ if (!isProtectedPath(pathname)) {
36
+ return NextResponse.next()
37
+ }
38
+
39
+ // Check for our session cookie (not bio-id's access token)
40
+ const sessionToken = request.cookies.get(`${COOKIE_PREFIX}_session`)?.value
41
+
42
+ if (!sessionToken) {
43
+ const loginUrl = new URL('/api/auth/login', request.url)
44
+ loginUrl.searchParams.set('returnTo', pathname)
45
+ return NextResponse.redirect(loginUrl)
46
+ }
47
+
48
+ // Verify our own session JWT (signed with JWT_SECRET, issued by APP_URL)
49
+ try {
50
+ const secret = new TextEncoder().encode(
51
+ process.env.JWT_SECRET || 'dev-only-secret-do-not-use-in-production'
52
+ )
53
+ await jwtVerify(sessionToken, secret, {
54
+ issuer: process.env.APP_URL || 'http://localhost:3000',
55
+ })
56
+ } catch {
57
+ // Session expired or invalid — redirect to login
58
+ const loginUrl = new URL('/api/auth/login', request.url)
59
+ loginUrl.searchParams.set('returnTo', pathname)
60
+ return NextResponse.redirect(loginUrl)
61
+ }
62
+
63
+ return NextResponse.next()
64
+ }
65
+
66
+ export const config = {
67
+ matcher: [
68
+ // Match all paths except static files and Next.js internals
69
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
70
+ ],
71
+ }