flowlink-auth 2.7.3 → 2.7.4
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/index.js +46 -1069
- package/package.json +4 -3
- package/src/ErrorBox.jsx +22 -0
- package/src/SignIn.jsx +212 -0
- package/src/SignUp.jsx +260 -0
- package/src/api.js +60 -0
- package/src/createAuthMiddleware.js +69 -0
- package/src/index.d.ts +15 -0
- package/src/index.js +5 -0
- package/src/init.js +100 -0
- package/src/provider.js +261 -0
- package/src/securityUtils.js +151 -0
- package/src/useAuth.js +13 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowlink-auth",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.4",
|
|
4
4
|
"description": "Custom auth library",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
|
-
"types": "
|
|
6
|
+
"types": "src/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
9
10
|
],
|
|
10
11
|
"scripts": {
|
|
11
12
|
"build": "esbuild src/*.js src/*.jsx --outdir=dist --bundle --format=esm --loader:.js=jsx --loader:.jsx=jsx",
|
package/src/ErrorBox.jsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/ErrorBox.jsx
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
export default function ErrorBox({ message }) {
|
|
5
|
+
const style = {
|
|
6
|
+
border: '1px solid #e53935',
|
|
7
|
+
background: '#fff6f6',
|
|
8
|
+
color: '#b71c1c',
|
|
9
|
+
padding: 12,
|
|
10
|
+
borderRadius: 8,
|
|
11
|
+
fontFamily: 'system-ui, Roboto, Arial',
|
|
12
|
+
maxWidth: 640,
|
|
13
|
+
margin: '8px auto'
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<div role="alert" style={style}>
|
|
17
|
+
<strong>flowlink Auth Error —</strong>
|
|
18
|
+
<div style={{ marginTop: 6 }}>{message}</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
package/src/SignIn.jsx
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// src/signin.jsx
|
|
2
|
+
'use client'
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { useAuth } from './provider.js'
|
|
5
|
+
|
|
6
|
+
export default function SignIn({ onSuccess } = {}) {
|
|
7
|
+
const {
|
|
8
|
+
publishableKey,
|
|
9
|
+
baseUrl,
|
|
10
|
+
redirect,
|
|
11
|
+
redirectTo,
|
|
12
|
+
user,
|
|
13
|
+
loadingUser,
|
|
14
|
+
completeLogin,
|
|
15
|
+
fetchMe,
|
|
16
|
+
setUser
|
|
17
|
+
} = useAuth()
|
|
18
|
+
|
|
19
|
+
const [email, setEmail] = useState('')
|
|
20
|
+
const [password, setPassword] = useState('')
|
|
21
|
+
const [loading, setLoading] = useState(false)
|
|
22
|
+
const [error, setError] = useState(null)
|
|
23
|
+
const [message, setMessage] = useState(null)
|
|
24
|
+
|
|
25
|
+
if (loadingUser) return null
|
|
26
|
+
|
|
27
|
+
if (user && redirect) {
|
|
28
|
+
if (typeof redirectTo === 'function') redirectTo(redirect)
|
|
29
|
+
else if (typeof window !== 'undefined') window.location.assign(redirect)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function submit(e) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
setError(null)
|
|
36
|
+
setMessage(null)
|
|
37
|
+
|
|
38
|
+
if (!email || !password) {
|
|
39
|
+
setError('Email and password are required')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setLoading(true)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const endpoint = `${(baseUrl || '').replace(/\/+$/, '')}/api/sdk/login`
|
|
47
|
+
|
|
48
|
+
const res = await fetch(endpoint, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
credentials: 'include',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'x-publishable-key': publishableKey || ''
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ email, password })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const ct = res.headers.get('content-type') || ''
|
|
59
|
+
let data = {}
|
|
60
|
+
if (ct.includes('application/json')) data = await res.json()
|
|
61
|
+
else {
|
|
62
|
+
const text = await res.text()
|
|
63
|
+
throw new Error(`Unexpected response (status ${res.status}): ${text.slice(0, 200)}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!res.ok) throw new Error(data.error || data.message || `Login failed (status ${res.status})`)
|
|
67
|
+
|
|
68
|
+
const serverUser = data.user ?? null
|
|
69
|
+
if (serverUser && typeof setUser === 'function') {
|
|
70
|
+
try { setUser(serverUser) } catch (_) {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof completeLogin === 'function') {
|
|
74
|
+
try {
|
|
75
|
+
await completeLogin()
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (typeof fetchMe === 'function') await fetchMe()
|
|
78
|
+
}
|
|
79
|
+
} else if (typeof fetchMe === 'function') {
|
|
80
|
+
await fetchMe()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (onSuccess) {
|
|
84
|
+
try { onSuccess(data) } catch (_) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setMessage('Signed in. Redirecting...')
|
|
88
|
+
if (redirect) {
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
if (typeof redirectTo === 'function') redirectTo(redirect)
|
|
91
|
+
else if (typeof window !== 'undefined') window.location.assign(redirect)
|
|
92
|
+
}, 250)
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
setError(err.message || 'Network error')
|
|
96
|
+
} finally {
|
|
97
|
+
setLoading(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- OAuth start flow (Google / GitHub) ---
|
|
102
|
+
async function startOAuthFlow(provider) {
|
|
103
|
+
setError(null)
|
|
104
|
+
setLoading(true)
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const rid =
|
|
108
|
+
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
|
109
|
+
? crypto.randomUUID()
|
|
110
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
111
|
+
|
|
112
|
+
// Use main app signin page as callback so user lands on /signin after flow
|
|
113
|
+
const callbackUrl = encodeURIComponent(`${window.location.origin}/signin`)
|
|
114
|
+
|
|
115
|
+
const sdkBase = (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_FLOWLINK_BASE_URL)
|
|
116
|
+
|| baseUrl || 'http://localhost:3001'
|
|
117
|
+
const startUrl = `${sdkBase.replace(/\/+$/, '')}/sdk/auth/start?rid=${rid}&source=${encodeURIComponent(provider)}&callbackUrl=${callbackUrl}`
|
|
118
|
+
|
|
119
|
+
if (!publishableKey) {
|
|
120
|
+
throw new Error('Missing publishable key (client side). Set NEXT_PUBLIC_FLOWLINK_PUBLISHABLE_KEY or provide publishableKey in provider.')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const res = await fetch(startUrl, {
|
|
124
|
+
method: 'GET',
|
|
125
|
+
headers: { 'x-publishable-key': publishableKey },
|
|
126
|
+
mode: 'cors'
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const data = await res.json().catch(() => null)
|
|
130
|
+
if (!res.ok) throw new Error(data?.error || `OAuth start failed (${res.status})`)
|
|
131
|
+
if (!data?.oauthUrl) throw new Error('SDK start did not return oauthUrl')
|
|
132
|
+
|
|
133
|
+
window.location.href = data.oauthUrl
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('OAuth start error:', err)
|
|
136
|
+
setError(err?.message || 'OAuth start failed')
|
|
137
|
+
setLoading(false)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const handleGoogle = (e) => {
|
|
142
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault()
|
|
143
|
+
startOAuthFlow('google')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const handleGithub = (e) => {
|
|
147
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault()
|
|
148
|
+
startOAuthFlow('github')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div style={overlay}>
|
|
153
|
+
<div style={modal}>
|
|
154
|
+
<h2 style={title}>Sign in</h2>
|
|
155
|
+
<p style={subtitle}>Welcome back — enter your credentials.</p>
|
|
156
|
+
|
|
157
|
+
<form onSubmit={submit} style={{ width: '100%' }}>
|
|
158
|
+
<label style={label}>Email</label>
|
|
159
|
+
<input
|
|
160
|
+
style={input}
|
|
161
|
+
value={email}
|
|
162
|
+
onChange={e => setEmail(e.target.value)}
|
|
163
|
+
type="email"
|
|
164
|
+
required
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
<label style={label}>Password</label>
|
|
168
|
+
<input
|
|
169
|
+
style={input}
|
|
170
|
+
type="password"
|
|
171
|
+
value={password}
|
|
172
|
+
onChange={e => setPassword(e.target.value)}
|
|
173
|
+
required
|
|
174
|
+
/>
|
|
175
|
+
|
|
176
|
+
<div style={{ marginTop: 12 }}>
|
|
177
|
+
<button style={button} type="submit" disabled={loading}>
|
|
178
|
+
{loading ? 'Signing in…' : 'Sign in'}
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
183
|
+
<button type="button" onClick={handleGoogle} style={oauthButtonGoogle} disabled={loading}>
|
|
184
|
+
Continue with Google
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
<button type="button" onClick={handleGithub} style={oauthButtonGithub} disabled={loading}>
|
|
188
|
+
Continue with GitHub
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{error && <div style={errorBox}>{error}</div>}
|
|
193
|
+
{message && <div style={successBox}>{message}</div>}
|
|
194
|
+
</form>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* styles */
|
|
201
|
+
const overlay = { position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.45)', zIndex: 9999, padding: 20 }
|
|
202
|
+
const modal = { width: '100%', maxWidth: 420, background: '#0f1724', color: '#fff', borderRadius: 12, padding: 22, boxShadow: '0 10px 30px rgba(2,6,23,0.6)', border: '1px solid rgba(255,255,255,0.04)' }
|
|
203
|
+
const title = { margin: 0, fontSize: 20, fontWeight: 600 }
|
|
204
|
+
const subtitle = { marginTop: 6, marginBottom: 14, color: '#cbd5e1', fontSize: 13 }
|
|
205
|
+
const label = { display: 'block', color: '#cbd5e1', fontSize: 13, marginTop: 8 }
|
|
206
|
+
const input = { width: '100%', padding: '10px 12px', marginTop: 6, borderRadius: 8, border: '1px solid rgba(255,255,255,0.06)', background: '#0b1220', color: '#fff', boxSizing: 'border-box' }
|
|
207
|
+
const button = { width: '100%', padding: '10px 12px', borderRadius: 8, background: 'linear-gradient(90deg,#06b6d4,#2563eb)', color: '#0b1220', border: 'none', fontWeight: 700, cursor: 'pointer' }
|
|
208
|
+
const oauthButtonGoogle = { flex: 1, padding: '10px 12px', borderRadius: 8, background: '#db4437', color: '#fff', border: 'none', cursor: 'pointer' }
|
|
209
|
+
const oauthButtonGithub = { flex: 1, padding: '10px 12px', borderRadius: 8, background: '#24292f', color: '#fff', border: 'none', cursor: 'pointer' }
|
|
210
|
+
const errorBox = { marginTop: 10, color: '#ffb4b4', fontSize: 13 }
|
|
211
|
+
const successBox = { marginTop: 10, color: '#bef264', fontSize: 13 }
|
|
212
|
+
|
package/src/SignUp.jsx
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// src/signup.jsx
|
|
2
|
+
'use client'
|
|
3
|
+
import React, { useState } from 'react'
|
|
4
|
+
import { useAuth } from './provider.js'
|
|
5
|
+
|
|
6
|
+
export default function SignUp() {
|
|
7
|
+
const {
|
|
8
|
+
publishableKey,
|
|
9
|
+
baseUrl,
|
|
10
|
+
redirect,
|
|
11
|
+
redirectTo,
|
|
12
|
+
user,
|
|
13
|
+
loadingUser,
|
|
14
|
+
fetchMe,
|
|
15
|
+
setUser
|
|
16
|
+
} = useAuth()
|
|
17
|
+
|
|
18
|
+
const [name, setName] = useState('')
|
|
19
|
+
const [email, setEmail] = useState('')
|
|
20
|
+
const [password, setPassword] = useState('')
|
|
21
|
+
const [loading, setLoading] = useState(false)
|
|
22
|
+
const [error, setError] = useState(null)
|
|
23
|
+
const [message, setMessage] = useState(null)
|
|
24
|
+
|
|
25
|
+
if (loadingUser) return null
|
|
26
|
+
|
|
27
|
+
if (user && redirect) {
|
|
28
|
+
if (typeof redirectTo === 'function') redirectTo(redirect)
|
|
29
|
+
else if (typeof window !== 'undefined') window.location.assign(redirect)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function submit(e) {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
setError(null)
|
|
36
|
+
setMessage(null)
|
|
37
|
+
setLoading(true)
|
|
38
|
+
|
|
39
|
+
const url = `${(baseUrl || '').replace(/\/+$/, '')}/api/sdk/signup`
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(url, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
credentials: 'include',
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
'x-publishable-key': publishableKey || ''
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({ name, email, password })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const data = await res.json().catch(() => ({}))
|
|
53
|
+
if (!res.ok) throw new Error(data.error || 'Signup failed')
|
|
54
|
+
|
|
55
|
+
if (data.user && typeof setUser === 'function') setUser(data.user)
|
|
56
|
+
if (typeof fetchMe === 'function') await fetchMe()
|
|
57
|
+
|
|
58
|
+
setMessage('Account created. Redirecting…')
|
|
59
|
+
|
|
60
|
+
if (redirect) {
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
if (typeof redirectTo === 'function') redirectTo(redirect)
|
|
63
|
+
else if (typeof window !== 'undefined') window.location.assign(redirect)
|
|
64
|
+
}, 300)
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err?.message ?? 'Network error')
|
|
68
|
+
console.error('Signup error:', err)
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startOAuthFlow(provider) {
|
|
75
|
+
// prevent double clicks
|
|
76
|
+
setError(null)
|
|
77
|
+
setLoading(true)
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const rid =
|
|
81
|
+
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
|
82
|
+
? crypto.randomUUID()
|
|
83
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
84
|
+
|
|
85
|
+
// callback must match what your SDK start expects and what Google console allows
|
|
86
|
+
const callbackUrl = encodeURIComponent(`${window.location.origin}/signup`)
|
|
87
|
+
|
|
88
|
+
// build start URL (server returns { oauthUrl })
|
|
89
|
+
const sdkBase = (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_FLOWLINK_BASE_URL)
|
|
90
|
+
|| baseUrl || window.location.origin.replace(/\/+$/, '')
|
|
91
|
+
const startUrl = `${sdkBase}/sdk/auth/start?rid=${rid}&source=${encodeURIComponent(provider)}&callbackUrl=${callbackUrl}`
|
|
92
|
+
|
|
93
|
+
// ensure publishableKey exists
|
|
94
|
+
if (!publishableKey) {
|
|
95
|
+
throw new Error('Missing publishable key (client side). Set NEXT_PUBLIC_FLOWLINK_PUBLISHABLE_KEY or provide publishableKey in provider.')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const res = await fetch(startUrl, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
headers: {
|
|
101
|
+
'x-publishable-key': publishableKey
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const data = await res.json().catch(() => null)
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new Error(data?.error || `OAuth start failed (${res.status})`)
|
|
108
|
+
}
|
|
109
|
+
if (!data?.oauthUrl) {
|
|
110
|
+
throw new Error('SDK start did not return oauthUrl')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// navigate to provider (Google/GitHub)
|
|
114
|
+
window.location.href = data.oauthUrl
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error('OAuth start error:', err)
|
|
117
|
+
setError(err?.message || 'OAuth start failed')
|
|
118
|
+
setLoading(false)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handleGoogle = (e) => {
|
|
123
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault()
|
|
124
|
+
startOAuthFlow('google')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const handleGithub = (e) => {
|
|
128
|
+
if (e && typeof e.preventDefault === 'function') e.preventDefault()
|
|
129
|
+
startOAuthFlow('github')
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<div style={overlay}>
|
|
133
|
+
<div style={modal}>
|
|
134
|
+
<h2 style={title}>Create account</h2>
|
|
135
|
+
|
|
136
|
+
<form onSubmit={submit}>
|
|
137
|
+
<label style={label}>Full name</label>
|
|
138
|
+
<input
|
|
139
|
+
name="name"
|
|
140
|
+
style={input}
|
|
141
|
+
value={name}
|
|
142
|
+
onChange={e => setName(e.target.value)}
|
|
143
|
+
autoComplete="name"
|
|
144
|
+
/>
|
|
145
|
+
|
|
146
|
+
<label style={label}>Email</label>
|
|
147
|
+
<input
|
|
148
|
+
name="email"
|
|
149
|
+
type="email"
|
|
150
|
+
style={input}
|
|
151
|
+
value={email}
|
|
152
|
+
onChange={e => setEmail(e.target.value)}
|
|
153
|
+
required
|
|
154
|
+
autoComplete="email"
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<label style={label}>Password</label>
|
|
158
|
+
<input
|
|
159
|
+
name="password"
|
|
160
|
+
type="password"
|
|
161
|
+
style={input}
|
|
162
|
+
value={password}
|
|
163
|
+
onChange={e => setPassword(e.target.value)}
|
|
164
|
+
required
|
|
165
|
+
autoComplete="new-password"
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<div style={{ marginTop: 12 }}>
|
|
169
|
+
<button style={button} type="submit" disabled={loading}>
|
|
170
|
+
{loading ? 'Creating...' : 'Create account'}
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={handleGoogle}
|
|
178
|
+
style={oauthButtonGoogle}
|
|
179
|
+
>
|
|
180
|
+
Continue with Google
|
|
181
|
+
</button>
|
|
182
|
+
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={handleGithub}
|
|
186
|
+
style={oauthButtonGithub}
|
|
187
|
+
>
|
|
188
|
+
Continue with GitHub
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{error && <div style={errorBox}>{error}</div>}
|
|
193
|
+
{message && <div style={successBox}>{message}</div>}
|
|
194
|
+
</form>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const overlay = {
|
|
201
|
+
position: 'fixed',
|
|
202
|
+
inset: 0,
|
|
203
|
+
background: 'rgba(0,0,0,0.5)',
|
|
204
|
+
display: 'flex',
|
|
205
|
+
justifyContent: 'center',
|
|
206
|
+
alignItems: 'center',
|
|
207
|
+
padding: 20,
|
|
208
|
+
zIndex: 9999
|
|
209
|
+
}
|
|
210
|
+
const modal = {
|
|
211
|
+
width: '100%',
|
|
212
|
+
maxWidth: 420,
|
|
213
|
+
background: '#0f1724',
|
|
214
|
+
borderRadius: 12,
|
|
215
|
+
padding: 24,
|
|
216
|
+
color: '#fff'
|
|
217
|
+
}
|
|
218
|
+
const title = { margin: 0, fontSize: 20, fontWeight: 600 }
|
|
219
|
+
const label = { marginTop: 10, display: 'block', fontSize: 13 }
|
|
220
|
+
const input = {
|
|
221
|
+
width: '100%',
|
|
222
|
+
padding: '10px 12px',
|
|
223
|
+
borderRadius: 8,
|
|
224
|
+
background: '#0b1220',
|
|
225
|
+
color: '#fff',
|
|
226
|
+
border: '1px solid #1e293b',
|
|
227
|
+
marginTop: 6
|
|
228
|
+
}
|
|
229
|
+
const button = {
|
|
230
|
+
marginTop: 15,
|
|
231
|
+
width: '100%',
|
|
232
|
+
padding: '10px 12px',
|
|
233
|
+
borderRadius: 8,
|
|
234
|
+
background: '#2563eb',
|
|
235
|
+
border: 'none',
|
|
236
|
+
color: '#fff',
|
|
237
|
+
fontWeight: 600,
|
|
238
|
+
cursor: 'pointer'
|
|
239
|
+
}
|
|
240
|
+
const oauthButtonGoogle = {
|
|
241
|
+
flex: 1,
|
|
242
|
+
padding: '10px 12px',
|
|
243
|
+
borderRadius: 8,
|
|
244
|
+
background: '#db4437',
|
|
245
|
+
color: '#fff',
|
|
246
|
+
border: 'none',
|
|
247
|
+
cursor: 'pointer'
|
|
248
|
+
}
|
|
249
|
+
const oauthButtonGithub = {
|
|
250
|
+
flex: 1,
|
|
251
|
+
padding: '10px 12px',
|
|
252
|
+
borderRadius: 8,
|
|
253
|
+
background: '#24292f',
|
|
254
|
+
color: '#fff',
|
|
255
|
+
border: 'none',
|
|
256
|
+
cursor: 'pointer'
|
|
257
|
+
}
|
|
258
|
+
const errorBox = { marginTop: 10, color: '#ffb4b4', fontSize: 13 }
|
|
259
|
+
const successBox = { marginTop: 10, color: '#bef264', fontSize: 13 }
|
|
260
|
+
|
package/src/api.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/api.js
|
|
2
|
+
import { getConfigSafe, getCSRFToken } from './init.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Secure API call wrapper with CSRF protection and validation
|
|
6
|
+
*/
|
|
7
|
+
export const makeApi = () => {
|
|
8
|
+
const cfg = getConfigSafe()
|
|
9
|
+
return {
|
|
10
|
+
call: async (path, opts = {}) => {
|
|
11
|
+
if (!cfg) {
|
|
12
|
+
return { status: 400, body: { error: 'flowlink-auth: SDK not initialized (publishable key missing)' } }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Security: Validate path
|
|
16
|
+
if (!path || typeof path !== 'string') {
|
|
17
|
+
return { status: 400, body: { error: 'Invalid API path' } }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Security: Prevent open redirect
|
|
21
|
+
if (path.startsWith('//') || path.startsWith('http')) {
|
|
22
|
+
return { status: 400, body: { error: 'Invalid API path' } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const headers = {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
'x-publishable-key': cfg.publishableKey,
|
|
28
|
+
...(opts.headers || {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Security: Add CSRF token if available
|
|
32
|
+
const csrfToken = getCSRFToken()
|
|
33
|
+
if (csrfToken && opts.method && opts.method.toUpperCase() !== 'GET') {
|
|
34
|
+
headers['x-csrf-token'] = csrfToken
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Security: Validate URL before fetch
|
|
39
|
+
const fullUrl = new URL(cfg.baseUrl + path).href
|
|
40
|
+
|
|
41
|
+
const res = await fetch(fullUrl, {
|
|
42
|
+
...opts,
|
|
43
|
+
headers,
|
|
44
|
+
// Security: Include credentials for cookie-based auth
|
|
45
|
+
credentials: 'include',
|
|
46
|
+
// Security: Enforce no referrer for sensitive requests
|
|
47
|
+
referrerPolicy: 'strict-origin-when-cross-origin'
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const body = await res.json().catch(() => null)
|
|
51
|
+
return { status: res.status, body }
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Security: Don't leak error details
|
|
54
|
+
console.error('API call failed:', err.message)
|
|
55
|
+
return { status: 500, body: { error: 'Request failed. Please try again.' } }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// flowlink-auth/src/createAuthMiddleware.js
|
|
2
|
+
import { NextResponse } from 'next/server'
|
|
3
|
+
import { jwtVerify } from 'jose'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory that returns a Next.js middleware function.
|
|
7
|
+
* - secret: JWT HMAC secret (string)
|
|
8
|
+
* - redirectTo: where to send unauthenticated users (default '/signin')
|
|
9
|
+
*
|
|
10
|
+
* Note: the app's middleware.ts should export the returned function as default
|
|
11
|
+
* and set `export const config = { matcher: [...] }` for paths to protect.
|
|
12
|
+
*/
|
|
13
|
+
export function createAuthMiddleware({ secret, redirectTo = '/signin', publicPaths = [] } = {}) {
|
|
14
|
+
if (!secret) {
|
|
15
|
+
throw new Error('createAuthMiddleware: secret is required')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (secret.length < 32) {
|
|
19
|
+
console.warn('createAuthMiddleware: secret should be at least 32 characters long for security')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return async function middleware(req) {
|
|
23
|
+
const url = req.nextUrl.clone()
|
|
24
|
+
const pathname = req.nextUrl.pathname
|
|
25
|
+
|
|
26
|
+
// Check if path is public (no auth required)
|
|
27
|
+
if (publicPaths && publicPaths.some(path => pathname.startsWith(path))) {
|
|
28
|
+
return NextResponse.next()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 1) Read token from cookie (secure, httpOnly)
|
|
32
|
+
const token = req.cookies.get?.('flowlink_token')?.value ?? null
|
|
33
|
+
|
|
34
|
+
if (!token) {
|
|
35
|
+
// No token -> redirect to sign-in
|
|
36
|
+
url.pathname = redirectTo
|
|
37
|
+
return NextResponse.redirect(url)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2) Validate token signature & expiry
|
|
41
|
+
try {
|
|
42
|
+
// Security: Use jose for secure JWT verification
|
|
43
|
+
const key = new TextEncoder().encode(secret)
|
|
44
|
+
const verified = await jwtVerify(token, key, {
|
|
45
|
+
algorithms: ['HS256'] // Restrict to HS256
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// 3) Security: Add user info to request headers for downstream services
|
|
49
|
+
const requestHeaders = new Headers(req.headers)
|
|
50
|
+
requestHeaders.set('x-user-id', verified.payload.userId || '')
|
|
51
|
+
requestHeaders.set('x-tenant-id', verified.payload.tenantId || '')
|
|
52
|
+
|
|
53
|
+
// token OK -> continue with verified user info
|
|
54
|
+
return NextResponse.next({
|
|
55
|
+
request: {
|
|
56
|
+
headers: requestHeaders
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
} catch (err) {
|
|
60
|
+
// Security: Log failed verification but don't expose details to user
|
|
61
|
+
console.error('Token verification failed:', err.message)
|
|
62
|
+
|
|
63
|
+
// invalid/expired token -> redirect to sign-in
|
|
64
|
+
url.pathname = redirectTo
|
|
65
|
+
return NextResponse.redirect(url)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export interface FlowlinkAuthProviderProps {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
publishableKey?: string;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
redirect?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export declare function FlowlinkAuthProvider(props: FlowlinkAuthProviderProps): JSX.Element;
|
|
11
|
+
export declare function initFlowlinkAuth(opts: { publishableKey: string; baseUrl: string; opts?: any }): void;
|
|
12
|
+
export declare function SignIn(props?: any): JSX.Element;
|
|
13
|
+
export declare function SignUp(props?: any): JSX.Element;
|
|
14
|
+
export declare function useAuth(): any;
|
|
15
|
+
|