@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.
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +6 -52
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +40 -57
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +10 -35
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/sample.d.ts +2 -0
- package/dist/commands/sample.d.ts.map +1 -1
- package/dist/commands/sample.js +140 -12
- package/dist/commands/sample.js.map +1 -1
- package/dist/commands/versions.d.ts.map +1 -1
- package/dist/commands/versions.js +10 -56
- package/dist/commands/versions.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/builder.d.ts.map +1 -1
- package/dist/lib/builder.js +58 -12
- package/dist/lib/builder.js.map +1 -1
- package/dist/lib/watch.d.ts +26 -0
- package/dist/lib/watch.d.ts.map +1 -0
- package/dist/lib/watch.js +136 -0
- package/dist/lib/watch.js.map +1 -0
- package/dist/templates/nextjs-oauth/.dockerignore +6 -0
- package/dist/templates/nextjs-oauth/.env.example +12 -0
- package/dist/templates/nextjs-oauth/Dockerfile +51 -0
- package/dist/templates/nextjs-oauth/README.md +115 -0
- package/dist/templates/nextjs-oauth/catalog-info.yaml +17 -0
- package/dist/templates/nextjs-oauth/helm/Chart.yaml +6 -0
- package/dist/templates/nextjs-oauth/helm/templates/_helpers.tpl +49 -0
- package/dist/templates/nextjs-oauth/helm/templates/configmap.yaml +10 -0
- package/dist/templates/nextjs-oauth/helm/templates/deployment.yaml +56 -0
- package/dist/templates/nextjs-oauth/helm/templates/ingress.yaml +41 -0
- package/dist/templates/nextjs-oauth/helm/templates/service.yaml +15 -0
- package/dist/templates/nextjs-oauth/helm/values.yaml +69 -0
- package/dist/templates/nextjs-oauth/helm/{{name}}/Chart.yaml +9 -0
- package/dist/templates/nextjs-oauth/helm/{{name}}/templates/deployment.yaml +68 -0
- package/dist/templates/nextjs-oauth/helm/{{name}}/templates/service.yaml +17 -0
- package/dist/templates/nextjs-oauth/helm/{{name}}/values.yaml +51 -0
- package/dist/templates/nextjs-oauth/next.config.js +23 -0
- package/dist/templates/nextjs-oauth/package.json +30 -0
- package/dist/templates/nextjs-oauth/public/.gitkeep +0 -0
- package/dist/templates/nextjs-oauth/src/app/api/auth/callback/route.ts +77 -0
- package/dist/templates/nextjs-oauth/src/app/api/auth/login/route.ts +16 -0
- package/dist/templates/nextjs-oauth/src/app/api/auth/logout/route.ts +7 -0
- package/dist/templates/nextjs-oauth/src/app/api/auth/session/route.ts +42 -0
- package/dist/templates/nextjs-oauth/src/app/api/example/route.ts +63 -0
- package/dist/templates/nextjs-oauth/src/app/api/health/route.ts +10 -0
- package/dist/templates/nextjs-oauth/src/app/dashboard/page.tsx +92 -0
- package/dist/templates/nextjs-oauth/src/app/layout.tsx +18 -0
- package/dist/templates/nextjs-oauth/src/app/page.tsx +110 -0
- package/dist/templates/nextjs-oauth/src/lib/auth.ts +285 -0
- package/dist/templates/nextjs-oauth/src/middleware.ts +71 -0
- package/dist/templates/nextjs-oauth/tsconfig.json +26 -0
- package/dist/types/index.d.ts +9 -5
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|