flowlink-auth 2.7.3 → 2.7.5
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.css +694 -0
- package/dist/index.js +701 -1134
- package/package.json +4 -3
- package/src/ErrorBox.jsx +22 -0
- package/src/SignIn.jsx +212 -0
- package/src/SignUp.jsx +747 -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/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
|
+
|
package/src/index.js
ADDED
package/src/init.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/init.js
|
|
2
|
+
let CONFIG = null
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Initialize the SDK globally (alternative to using flowlinkAuthProvider props).
|
|
6
|
+
* publishableKey: string (required)
|
|
7
|
+
* baseUrl: string (required)
|
|
8
|
+
* opts: { requireServerSecret?: boolean, serverSecret?: string } - serverSecret ONLY for dev/testing
|
|
9
|
+
*/
|
|
10
|
+
export function initFlowlinkAuth({ publishableKey, baseUrl, opts } = {}) {
|
|
11
|
+
if (!publishableKey) throw new Error('flowlink-auth: publishableKey is required in initFlowlinkAuth()')
|
|
12
|
+
if (!baseUrl) throw new Error('flowlink-auth: baseUrl is required in initFlowlinkAuth()')
|
|
13
|
+
|
|
14
|
+
// Security: Validate publishable key format
|
|
15
|
+
if (!publishableKey.startsWith('pk_')) {
|
|
16
|
+
console.warn('flowlink-auth: publishableKey should start with "pk_"')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Security: Enforce HTTPS in production
|
|
20
|
+
if (typeof window !== 'undefined' && window.location.protocol === 'http:' && !baseUrl.includes('localhost')) {
|
|
21
|
+
console.error('flowlink-auth: HTTPS is required for production. Current protocol:', window.location.protocol)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Security: Validate baseUrl is properly formatted
|
|
25
|
+
const urlObj = new URL(baseUrl).href
|
|
26
|
+
if (!urlObj) {
|
|
27
|
+
throw new Error('flowlink-auth: baseUrl must be a valid URL')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
CONFIG = {
|
|
31
|
+
publishableKey,
|
|
32
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
33
|
+
requireServerSecret: opts?.requireServerSecret || false,
|
|
34
|
+
secretKey: opts?.serverSecret || null,
|
|
35
|
+
csrfToken: generateCSRFToken() // Add CSRF protection
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (CONFIG.requireServerSecret && !CONFIG.secretKey) {
|
|
39
|
+
console.warn('flowlink-auth: requireServerSecret=true but serverSecret not provided. This is insecure for production.')
|
|
40
|
+
}
|
|
41
|
+
if (CONFIG.secretKey) {
|
|
42
|
+
console.error('flowlink-auth: secretKey should NEVER be provided to client SDK. Use server-side authentication only.')
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate CSRF token for form submissions
|
|
48
|
+
*/
|
|
49
|
+
function generateCSRFToken() {
|
|
50
|
+
if (typeof window === 'undefined') return null
|
|
51
|
+
const array = new Uint8Array(32)
|
|
52
|
+
window.crypto.getRandomValues(array)
|
|
53
|
+
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Called by flowlinkAuthProvider so provider props set the same shared CONFIG.
|
|
58
|
+
* This allows either initflowlinkAuth(...) or provider props to work.
|
|
59
|
+
*/
|
|
60
|
+
export function setConfigFromProvider({ publishableKey, secretKey = null, baseUrl, opts = {} } = {}) {
|
|
61
|
+
if (!publishableKey || !baseUrl) {
|
|
62
|
+
// don't throw here — provider already validates and shows friendly UI
|
|
63
|
+
CONFIG = null
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Security: Validate HTTPS
|
|
68
|
+
if (typeof window !== 'undefined' && window.location.protocol === 'http:' && !baseUrl.includes('localhost')) {
|
|
69
|
+
console.warn('flowlink-auth: HTTPS recommended for production')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
CONFIG = {
|
|
73
|
+
publishableKey,
|
|
74
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
75
|
+
requireServerSecret: opts?.requireServerSecret || false,
|
|
76
|
+
secretKey: secretKey || null,
|
|
77
|
+
csrfToken: generateCSRFToken()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (secretKey) {
|
|
81
|
+
console.error('flowlink-auth: NEVER pass secretKey to client-side code')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Safe getter used by UI components (returns null if not initialized) */
|
|
86
|
+
export function getConfigSafe() {
|
|
87
|
+
return CONFIG
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Strict getter used by server helpers (throws if not initialized) */
|
|
91
|
+
export function getConfig() {
|
|
92
|
+
if (!CONFIG) throw new Error('flowlink-auth: SDK not initialized. Call initFlowlinkAuth(...) or wrap components in FlowlinkAuthProvider with keys.')
|
|
93
|
+
return CONFIG
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Get CSRF token for forms */
|
|
97
|
+
export function getCSRFToken() {
|
|
98
|
+
return CONFIG?.csrfToken || null
|
|
99
|
+
}
|
|
100
|
+
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// provider.js
|
|
2
|
+
'use client'
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'
|
|
4
|
+
import { checkSecureContext, validateEmail, getSafeErrorMessage } from './securityUtils.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* flowlinkAuthProvider (cookie-based)
|
|
8
|
+
*
|
|
9
|
+
* - Backend sets HttpOnly cookie (flowlink_token) on login/signup
|
|
10
|
+
* - Frontend calls /api/sdk/me with credentials:'include' to validate session
|
|
11
|
+
* - completeLogin() waits for /me and then optionally redirects (replace by default)
|
|
12
|
+
* - logout() clears client state and calls server logout (if available)
|
|
13
|
+
* - other tabs are notified via localStorage events (flowlink_login / flowlink_logout)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const AuthContext = createContext(null)
|
|
17
|
+
|
|
18
|
+
const FlowlinkAuthProvider = ({ children, publishableKey, baseUrl, redirect }) => {
|
|
19
|
+
const [ready, setReady] = useState(false)
|
|
20
|
+
const [error, setError] = useState(null)
|
|
21
|
+
|
|
22
|
+
const [user, setUser] = useState(null)
|
|
23
|
+
const [loadingUser, setLoadingUser] = useState(true)
|
|
24
|
+
const [sessionTimeout, setSessionTimeout] = useState(null)
|
|
25
|
+
|
|
26
|
+
const redirectedRef = useRef(false) // prevent multiple automatic redirects
|
|
27
|
+
const sessionTimerRef = useRef(null) // track session timeout timer
|
|
28
|
+
|
|
29
|
+
// Security: Check HTTPS context
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
checkSecureContext()
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
/* Validate config */
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!publishableKey || !publishableKey.trim()) {
|
|
37
|
+
setError('Missing publishable key')
|
|
38
|
+
setReady(false)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if (!baseUrl || !baseUrl.trim()) {
|
|
42
|
+
setError('Missing baseUrl')
|
|
43
|
+
setReady(false)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Security: Validate publishable key format
|
|
48
|
+
if (!publishableKey.startsWith('pk_')) {
|
|
49
|
+
console.warn('flowlink-auth: publishableKey should start with "pk_"')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setError(null)
|
|
53
|
+
setReady(true)
|
|
54
|
+
}, [publishableKey, baseUrl])
|
|
55
|
+
|
|
56
|
+
const normalizedBase = useCallback(() => {
|
|
57
|
+
return baseUrl?.replace(/\/+$/, '') || ''
|
|
58
|
+
}, [baseUrl])
|
|
59
|
+
|
|
60
|
+
const redirectTo = (url, { replace = true } = {}) => {
|
|
61
|
+
if (!url) return
|
|
62
|
+
if (typeof window === 'undefined') return
|
|
63
|
+
|
|
64
|
+
// Security: Validate redirect URL (prevent open redirect)
|
|
65
|
+
if (url.startsWith('//') || url.startsWith('http')) {
|
|
66
|
+
console.error('flowlink-auth: Redirect URL must be relative')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
if (replace) window.location.replace(url)
|
|
72
|
+
else window.location.assign(url)
|
|
73
|
+
} catch (_) {
|
|
74
|
+
window.location.href = url
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Security: Reset session timeout on activity
|
|
79
|
+
const resetSessionTimeout = useCallback(() => {
|
|
80
|
+
if (sessionTimerRef.current) {
|
|
81
|
+
clearTimeout(sessionTimerRef.current)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Set 24-hour session timeout
|
|
85
|
+
sessionTimerRef.current = setTimeout(() => {
|
|
86
|
+
setSessionTimeout(true)
|
|
87
|
+
logout()
|
|
88
|
+
}, 24 * 60 * 60 * 1000)
|
|
89
|
+
}, [])
|
|
90
|
+
|
|
91
|
+
/* Fetch /api/sdk/me using cookie (HttpOnly token) */
|
|
92
|
+
const fetchMe = useCallback(async () => {
|
|
93
|
+
setLoadingUser(true)
|
|
94
|
+
try {
|
|
95
|
+
const base = normalizedBase()
|
|
96
|
+
if (!base) {
|
|
97
|
+
setUser(null)
|
|
98
|
+
setLoadingUser(false)
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Security: Validate URL
|
|
103
|
+
const fullUrl = new URL(`${base}/api/sdk/me`).href
|
|
104
|
+
|
|
105
|
+
const res = await fetch(fullUrl, {
|
|
106
|
+
method: 'GET',
|
|
107
|
+
credentials: 'include',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json'
|
|
110
|
+
},
|
|
111
|
+
referrerPolicy: 'strict-origin-when-cross-origin'
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
setUser(null)
|
|
116
|
+
setLoadingUser(false)
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = await res.json()
|
|
121
|
+
const u = data?.user ?? null
|
|
122
|
+
setUser(u)
|
|
123
|
+
setLoadingUser(false)
|
|
124
|
+
|
|
125
|
+
// Reset session timeout on successful fetch
|
|
126
|
+
resetSessionTimeout()
|
|
127
|
+
|
|
128
|
+
return u
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('Failed to fetch user:', getSafeErrorMessage(err))
|
|
131
|
+
setUser(null)
|
|
132
|
+
setLoadingUser(false)
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}, [normalizedBase, resetSessionTimeout])
|
|
136
|
+
|
|
137
|
+
/* Initial bootstrap + storage listener (to sync across tabs) */
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!ready) return
|
|
140
|
+
fetchMe()
|
|
141
|
+
|
|
142
|
+
const onStorage = (e) => {
|
|
143
|
+
if (!e.key) return
|
|
144
|
+
if (e.key === 'flowlink_login' || e.key === 'flowlink_logout') {
|
|
145
|
+
// revalidate session when other tab signals a change
|
|
146
|
+
fetchMe()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (typeof window !== 'undefined') {
|
|
151
|
+
window.addEventListener('storage', onStorage)
|
|
152
|
+
}
|
|
153
|
+
return () => {
|
|
154
|
+
if (typeof window !== 'undefined') {
|
|
155
|
+
window.removeEventListener('storage', onStorage)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [ready, fetchMe])
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Auto-redirect when user becomes authenticated.
|
|
162
|
+
* Runs once per mount when loadingUser turns false and user exists.
|
|
163
|
+
*/
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!ready) return
|
|
166
|
+
if (loadingUser) return
|
|
167
|
+
if (!user) {
|
|
168
|
+
redirectedRef.current = false // reset flag when logged out
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (user && redirect && !redirectedRef.current) {
|
|
172
|
+
redirectedRef.current = true
|
|
173
|
+
redirectTo(redirect, { replace: true })
|
|
174
|
+
}
|
|
175
|
+
}, [ready, loadingUser, user, redirect])
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* completeLogin(opts)
|
|
179
|
+
* - call after server login/signup that sets HttpOnly cookie
|
|
180
|
+
* - will re-fetch /me, notify other tabs, and optionally redirect
|
|
181
|
+
* opts:
|
|
182
|
+
* - redirectTo: URL to navigate after successful validation (overrides provider redirect)
|
|
183
|
+
* - replace: boolean (default true)
|
|
184
|
+
*/
|
|
185
|
+
const completeLogin = useCallback(async (opts = {}) => {
|
|
186
|
+
const { redirectTo: redirectUrl, replace = true } = opts
|
|
187
|
+
const u = await fetchMe()
|
|
188
|
+
// notify other tabs
|
|
189
|
+
try { localStorage.setItem('flowlink_login', String(Date.now())) } catch (_) {}
|
|
190
|
+
// only redirect if we have a valid user
|
|
191
|
+
const dest = redirectUrl ?? redirect
|
|
192
|
+
if (u && dest) {
|
|
193
|
+
redirectTo(dest, { replace })
|
|
194
|
+
}
|
|
195
|
+
return u
|
|
196
|
+
}, [fetchMe, redirect])
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* logout(opts)
|
|
200
|
+
* - callServer: whether to call /api/sdk/logout (default true)
|
|
201
|
+
* - redirectTo: optional URL to go to after logout (overrides provider redirect)
|
|
202
|
+
*/
|
|
203
|
+
const logout = useCallback(async (opts = {}) => {
|
|
204
|
+
const { callServer = true, redirectTo: redirectUrl, replace = true } = opts
|
|
205
|
+
const base = normalizedBase()
|
|
206
|
+
|
|
207
|
+
if (callServer && base) {
|
|
208
|
+
try {
|
|
209
|
+
await fetch(`${base}/api/sdk/logout`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
credentials: 'include',
|
|
212
|
+
headers: { 'Content-Type': 'application/json' }
|
|
213
|
+
})
|
|
214
|
+
} catch (_) {
|
|
215
|
+
// ignore server errors; proceed to client cleanup
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
setUser(null)
|
|
220
|
+
try { localStorage.setItem('flowlink_logout', String(Date.now())) } catch (_) {}
|
|
221
|
+
|
|
222
|
+
const dest = redirectUrl ?? redirect
|
|
223
|
+
if (dest) redirectTo(dest, { replace })
|
|
224
|
+
}, [normalizedBase, redirect])
|
|
225
|
+
|
|
226
|
+
/* Provide context value */
|
|
227
|
+
const value = {
|
|
228
|
+
publishableKey,
|
|
229
|
+
baseUrl,
|
|
230
|
+
redirectTo: (url, opts) => redirectTo(url, opts),
|
|
231
|
+
user,
|
|
232
|
+
setUser,
|
|
233
|
+
loadingUser,
|
|
234
|
+
fetchMe,
|
|
235
|
+
logout,
|
|
236
|
+
completeLogin
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<AuthContext.Provider value={value}>
|
|
241
|
+
{error ? (
|
|
242
|
+
<div style={{ padding:'20px', background:'#220000', color:'white' }}>
|
|
243
|
+
<h2>flowlink Auth Error</h2>
|
|
244
|
+
<p>{error}</p>
|
|
245
|
+
</div>
|
|
246
|
+
) : !ready ? null : (
|
|
247
|
+
children
|
|
248
|
+
)}
|
|
249
|
+
</AuthContext.Provider>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const useAuth = () => {
|
|
254
|
+
const ctx = useContext(AuthContext)
|
|
255
|
+
if (!ctx) throw new Error('useAuth must be used within FlowlinkAuthProvider')
|
|
256
|
+
return ctx
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default FlowlinkAuthProvider
|
|
260
|
+
export { useAuth }
|
|
261
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/securityUtils.js
|
|
2
|
+
/**
|
|
3
|
+
* Security utilities for flowlink-auth SDK
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize user input to prevent XSS
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizeInput(input) {
|
|
10
|
+
if (typeof input !== 'string') return input
|
|
11
|
+
|
|
12
|
+
const div = document.createElement('div')
|
|
13
|
+
div.textContent = input
|
|
14
|
+
return div.innerHTML
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate email format
|
|
19
|
+
*/
|
|
20
|
+
export function validateEmail(email) {
|
|
21
|
+
if (typeof email !== 'string') return false
|
|
22
|
+
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
23
|
+
return regex.test(email) && email.length <= 255
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Validate password strength
|
|
28
|
+
*/
|
|
29
|
+
export function validatePasswordStrength(password) {
|
|
30
|
+
if (typeof password !== 'string') return false
|
|
31
|
+
if (password.length < 12) return false
|
|
32
|
+
|
|
33
|
+
const hasUpper = /[A-Z]/.test(password)
|
|
34
|
+
const hasLower = /[a-z]/.test(password)
|
|
35
|
+
const hasNumber = /[0-9]/.test(password)
|
|
36
|
+
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
|
|
37
|
+
|
|
38
|
+
return hasUpper && hasLower && hasNumber && hasSpecial
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get password strength feedback
|
|
43
|
+
*/
|
|
44
|
+
export function getPasswordFeedback(password) {
|
|
45
|
+
const feedback = []
|
|
46
|
+
|
|
47
|
+
if (password.length < 12) {
|
|
48
|
+
feedback.push('At least 12 characters')
|
|
49
|
+
}
|
|
50
|
+
if (!/[A-Z]/.test(password)) {
|
|
51
|
+
feedback.push('One uppercase letter')
|
|
52
|
+
}
|
|
53
|
+
if (!/[a-z]/.test(password)) {
|
|
54
|
+
feedback.push('One lowercase letter')
|
|
55
|
+
}
|
|
56
|
+
if (!/[0-9]/.test(password)) {
|
|
57
|
+
feedback.push('One number')
|
|
58
|
+
}
|
|
59
|
+
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
|
60
|
+
feedback.push('One special character')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return feedback
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if running on HTTPS (except localhost)
|
|
68
|
+
*/
|
|
69
|
+
export function isSecureContext() {
|
|
70
|
+
if (typeof window === 'undefined') return true
|
|
71
|
+
|
|
72
|
+
const isLocalhost = window.location.hostname === 'localhost' ||
|
|
73
|
+
window.location.hostname === '127.0.0.1' ||
|
|
74
|
+
window.location.hostname === '[::1]'
|
|
75
|
+
|
|
76
|
+
const isHttps = window.location.protocol === 'https:'
|
|
77
|
+
|
|
78
|
+
return isHttps || isLocalhost
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Warn if not on HTTPS in production
|
|
83
|
+
*/
|
|
84
|
+
export function checkSecureContext() {
|
|
85
|
+
if (!isSecureContext()) {
|
|
86
|
+
console.warn(
|
|
87
|
+
'flowlink-auth: HTTPS is required for production. ' +
|
|
88
|
+
'Your connection is not secure. Authentication may fail.'
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Safe error message handler (don't leak sensitive info)
|
|
95
|
+
*/
|
|
96
|
+
export function getSafeErrorMessage(error) {
|
|
97
|
+
if (typeof error === 'string') {
|
|
98
|
+
// Check for sensitive keywords
|
|
99
|
+
if (error.includes('password') || error.includes('token') || error.includes('secret')) {
|
|
100
|
+
return 'An error occurred. Please try again.'
|
|
101
|
+
}
|
|
102
|
+
return error
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (error?.message) {
|
|
106
|
+
return getSafeErrorMessage(error.message)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return 'An error occurred. Please try again.'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate nonce for CSP
|
|
114
|
+
*/
|
|
115
|
+
export function generateNonce() {
|
|
116
|
+
const array = new Uint8Array(16)
|
|
117
|
+
if (typeof window !== 'undefined' && window.crypto) {
|
|
118
|
+
window.crypto.getRandomValues(array)
|
|
119
|
+
}
|
|
120
|
+
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate origin matches expected domain
|
|
125
|
+
*/
|
|
126
|
+
export function validateOrigin(expectedOrigin) {
|
|
127
|
+
if (typeof window === 'undefined') return true
|
|
128
|
+
|
|
129
|
+
const currentOrigin = window.location.origin
|
|
130
|
+
|
|
131
|
+
return currentOrigin === expectedOrigin
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check for XSS vulnerabilities in stored data
|
|
136
|
+
*/
|
|
137
|
+
export function hasXSSPatterns(input) {
|
|
138
|
+
if (typeof input !== 'string') return false
|
|
139
|
+
|
|
140
|
+
const xssPatterns = [
|
|
141
|
+
/<script[^>]*>.*?<\/script>/gi,
|
|
142
|
+
/javascript:/gi,
|
|
143
|
+
/on\w+\s*=/gi,
|
|
144
|
+
/<iframe/gi,
|
|
145
|
+
/<object/gi,
|
|
146
|
+
/<embed/gi
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
return xssPatterns.some(pattern => pattern.test(input))
|
|
150
|
+
}
|
|
151
|
+
|
package/src/useAuth.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// 'use client'
|
|
2
|
+
import { useAuth } from './provider.js'
|
|
3
|
+
import { checkSecureContext } from './securityUtils.js'
|
|
4
|
+
|
|
5
|
+
// Check security context on module load
|
|
6
|
+
if (typeof window !== 'undefined') {
|
|
7
|
+
checkSecureContext()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Just re-export the hook from provider.js
|
|
11
|
+
export { useAuth }
|
|
12
|
+
|
|
13
|
+
|