unologin-nextjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/middleware/index.d.ts +8 -0
- package/dist/middleware/index.js +28 -0
- package/dist/packages/nextjs/src/index.d.ts +3 -0
- package/dist/packages/nextjs/src/index.js +3 -0
- package/dist/packages/nextjs/src/middleware/index.d.ts +8 -0
- package/dist/packages/nextjs/src/middleware/index.js +28 -0
- package/dist/packages/nextjs/src/react/SignIn.d.ts +18 -0
- package/dist/packages/nextjs/src/react/SignIn.js +76 -0
- package/dist/packages/nextjs/src/react/UserButton.d.ts +4 -0
- package/dist/packages/nextjs/src/react/UserButton.js +23 -0
- package/dist/packages/nextjs/src/react/UserProfile.d.ts +1 -0
- package/dist/packages/nextjs/src/react/UserProfile.js +13 -0
- package/dist/packages/nextjs/src/react/context.d.ts +10 -0
- package/dist/packages/nextjs/src/react/context.js +53 -0
- package/dist/packages/nextjs/src/react/index.d.ts +5 -0
- package/dist/packages/nextjs/src/react/index.js +5 -0
- package/dist/packages/nextjs/src/react/types.d.ts +7 -0
- package/dist/packages/nextjs/src/react/types.js +1 -0
- package/dist/packages/nextjs/src/server/client.d.ts +32 -0
- package/dist/packages/nextjs/src/server/client.js +164 -0
- package/dist/packages/nextjs/src/server/cookies.d.ts +10 -0
- package/dist/packages/nextjs/src/server/cookies.js +37 -0
- package/dist/packages/nextjs/src/server/crypto.d.ts +3 -0
- package/dist/packages/nextjs/src/server/crypto.js +38 -0
- package/dist/packages/nextjs/src/server/env.d.ts +11 -0
- package/dist/packages/nextjs/src/server/env.js +17 -0
- package/dist/packages/nextjs/src/server/handlers.d.ts +7 -0
- package/dist/packages/nextjs/src/server/handlers.js +84 -0
- package/dist/packages/nextjs/src/server/index.d.ts +4 -0
- package/dist/packages/nextjs/src/server/index.js +4 -0
- package/dist/packages/nextjs/src/server/types.d.ts +41 -0
- package/dist/packages/nextjs/src/server/types.js +1 -0
- package/dist/react/SignIn.d.ts +18 -0
- package/dist/react/SignIn.js +76 -0
- package/dist/react/UserButton.d.ts +4 -0
- package/dist/react/UserButton.js +23 -0
- package/dist/react/UserProfile.d.ts +1 -0
- package/dist/react/UserProfile.js +13 -0
- package/dist/react/context.d.ts +10 -0
- package/dist/react/context.js +53 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +5 -0
- package/dist/react/types.d.ts +7 -0
- package/dist/react/types.js +1 -0
- package/dist/sdk/src/client.d.ts +49 -0
- package/dist/sdk/src/client.js +152 -0
- package/dist/sdk/src/server.d.ts +2 -0
- package/dist/sdk/src/server.js +2 -0
- package/dist/sdk/src/types.d.ts +42 -0
- package/dist/sdk/src/types.js +1 -0
- package/dist/server/client.d.ts +31 -0
- package/dist/server/client.js +161 -0
- package/dist/server/cookies.d.ts +10 -0
- package/dist/server/cookies.js +37 -0
- package/dist/server/crypto.d.ts +3 -0
- package/dist/server/crypto.js +38 -0
- package/dist/server/env.d.ts +11 -0
- package/dist/server/env.js +17 -0
- package/dist/server/handlers.d.ts +7 -0
- package/dist/server/handlers.js +84 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/types.d.ts +41 -0
- package/dist/server/types.js +1 -0
- package/package.json +46 -0
- package/src/index.ts +3 -0
- package/src/middleware/index.ts +43 -0
- package/src/react/SignIn.tsx +124 -0
- package/src/react/UserButton.tsx +41 -0
- package/src/react/UserProfile.tsx +44 -0
- package/src/react/context.tsx +71 -0
- package/src/react/index.ts +5 -0
- package/src/react/types.ts +8 -0
- package/src/server/client.ts +201 -0
- package/src/server/cookies.ts +49 -0
- package/src/server/crypto.ts +46 -0
- package/src/server/env.ts +30 -0
- package/src/server/handlers.ts +96 -0
- package/src/server/index.ts +4 -0
- package/src/server/types.ts +44 -0
- package/tsconfig.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# unologin-nextjs
|
|
2
|
+
|
|
3
|
+
Clerk-like MVP toolkit for integrating UnoLogin into Next.js App Router:
|
|
4
|
+
|
|
5
|
+
- server helpers for OAuth callback/session cookies;
|
|
6
|
+
- route handler factory (`sign-in`, `callback`, `sign-out`, `session`);
|
|
7
|
+
- React provider/hooks (`UnoLoginProvider`, `useAuth`, `useUser`);
|
|
8
|
+
- ready components (`SignIn`, `UserButton`, `UserProfile`);
|
|
9
|
+
- middleware helper for protected routes.
|
|
10
|
+
|
|
11
|
+
See full guide in `docs/NEXTJS.md`.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
export interface AuthMiddlewareOptions {
|
|
4
|
+
signInPath?: string;
|
|
5
|
+
publicPaths?: string[];
|
|
6
|
+
sessionCookieName?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createAuthMiddleware(options?: AuthMiddlewareOptions): (request: NextRequest) => NextResponse;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
const DEFAULT_SESSION_COOKIE = '__unologin_session';
|
|
3
|
+
function pathMatches(pathname, path) {
|
|
4
|
+
return pathname === path || pathname.startsWith(`${path}/`);
|
|
5
|
+
}
|
|
6
|
+
export function createAuthMiddleware(options = {}) {
|
|
7
|
+
const signInPath = options.signInPath ?? '/auth';
|
|
8
|
+
const publicPaths = new Set(options.publicPaths ?? [signInPath, '/api/auth/callback', '/api/auth/sign-in']);
|
|
9
|
+
const sessionCookieName = options.sessionCookieName ?? DEFAULT_SESSION_COOKIE;
|
|
10
|
+
return function authMiddleware(request) {
|
|
11
|
+
const { pathname, search } = request.nextUrl;
|
|
12
|
+
if (Array.from(publicPaths).some((path) => pathMatches(pathname, path))) {
|
|
13
|
+
return NextResponse.next();
|
|
14
|
+
}
|
|
15
|
+
const hasSession = Boolean(request.cookies.get(sessionCookieName)?.value);
|
|
16
|
+
if (hasSession) {
|
|
17
|
+
return NextResponse.next();
|
|
18
|
+
}
|
|
19
|
+
// Prevent redirect loops if the current route is already auth-adjacent.
|
|
20
|
+
if (pathname.startsWith('/api/auth')) {
|
|
21
|
+
return NextResponse.next();
|
|
22
|
+
}
|
|
23
|
+
const redirectTo = `${pathname}${search}`;
|
|
24
|
+
const target = new URL('/api/auth/sign-in', request.url);
|
|
25
|
+
target.searchParams.set('redirect_to', redirectTo);
|
|
26
|
+
return NextResponse.redirect(target);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
export interface AuthMiddlewareOptions {
|
|
4
|
+
signInPath?: string;
|
|
5
|
+
publicPaths?: string[];
|
|
6
|
+
sessionCookieName?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createAuthMiddleware(options?: AuthMiddlewareOptions): (request: NextRequest) => NextResponse;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
const DEFAULT_SESSION_COOKIE = '__unologin_session';
|
|
3
|
+
function pathMatches(pathname, path) {
|
|
4
|
+
return pathname === path || pathname.startsWith(`${path}/`);
|
|
5
|
+
}
|
|
6
|
+
export function createAuthMiddleware(options = {}) {
|
|
7
|
+
const signInPath = options.signInPath ?? '/auth';
|
|
8
|
+
const publicPaths = new Set(options.publicPaths ?? [signInPath, '/api/auth/callback', '/api/auth/sign-in']);
|
|
9
|
+
const sessionCookieName = options.sessionCookieName ?? DEFAULT_SESSION_COOKIE;
|
|
10
|
+
return function authMiddleware(request) {
|
|
11
|
+
const { pathname, search } = request.nextUrl;
|
|
12
|
+
if (Array.from(publicPaths).some((path) => pathMatches(pathname, path))) {
|
|
13
|
+
return NextResponse.next();
|
|
14
|
+
}
|
|
15
|
+
const hasSession = Boolean(request.cookies.get(sessionCookieName)?.value);
|
|
16
|
+
if (hasSession) {
|
|
17
|
+
return NextResponse.next();
|
|
18
|
+
}
|
|
19
|
+
// Prevent redirect loops if the current route is already auth-adjacent.
|
|
20
|
+
if (pathname.startsWith('/api/auth')) {
|
|
21
|
+
return NextResponse.next();
|
|
22
|
+
}
|
|
23
|
+
const redirectTo = `${pathname}${search}`;
|
|
24
|
+
const target = new URL('/api/auth/sign-in', request.url);
|
|
25
|
+
target.searchParams.set('redirect_to', redirectTo);
|
|
26
|
+
return NextResponse.redirect(target);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
UnoLoginWidgetV2?: {
|
|
4
|
+
mount: (config: Record<string, unknown>) => void;
|
|
5
|
+
unmount: () => void;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export interface SignInProps {
|
|
10
|
+
clientId: string;
|
|
11
|
+
redirectUri: string;
|
|
12
|
+
unoLoginUrl: string;
|
|
13
|
+
enabledMethods?: string[];
|
|
14
|
+
locale?: 'ru' | 'en';
|
|
15
|
+
theme?: 'light' | 'dark' | 'system';
|
|
16
|
+
debug?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function SignIn({ clientId, redirectUri, unoLoginUrl, enabledMethods, locale, theme, debug, }: SignInProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
function loadScript(src) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const existing = document.querySelector(`script[data-unologin-widget-src="${src}"]`);
|
|
7
|
+
if (existing) {
|
|
8
|
+
if (window.UnoLoginWidgetV2) {
|
|
9
|
+
resolve();
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
existing.addEventListener('load', () => resolve(), { once: true });
|
|
13
|
+
existing.addEventListener('error', () => reject(new Error('Failed to load UnoLogin widget')), {
|
|
14
|
+
once: true,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const script = document.createElement('script');
|
|
20
|
+
script.src = src;
|
|
21
|
+
script.async = true;
|
|
22
|
+
script.dataset.unologinWidgetSrc = src;
|
|
23
|
+
script.onload = () => resolve();
|
|
24
|
+
script.onerror = () => reject(new Error('Failed to load UnoLogin widget'));
|
|
25
|
+
document.head.appendChild(script);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function SignIn({ clientId, redirectUri, unoLoginUrl, enabledMethods = ['vk', 'yandex', 'google', 'password', 'webauthn'], locale = 'ru', theme = 'light', debug = false, }) {
|
|
29
|
+
const containerRef = useRef(null);
|
|
30
|
+
const [error, setError] = useState(null);
|
|
31
|
+
const loginRequired = useMemo(() => {
|
|
32
|
+
if (typeof window === 'undefined') {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return new URLSearchParams(window.location.search).get('error') === 'login_required';
|
|
36
|
+
}, []);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const scriptUrl = `${unoLoginUrl.replace(/\/$/, '')}/widget/v2.js`;
|
|
39
|
+
let mounted = false;
|
|
40
|
+
loadScript(scriptUrl)
|
|
41
|
+
.then(() => {
|
|
42
|
+
if (!containerRef.current || !window.UnoLoginWidgetV2 || mounted) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
window.UnoLoginWidgetV2.mount({
|
|
46
|
+
container: containerRef.current,
|
|
47
|
+
clientId,
|
|
48
|
+
redirectUri,
|
|
49
|
+
enabledMethods,
|
|
50
|
+
locale,
|
|
51
|
+
theme,
|
|
52
|
+
debug,
|
|
53
|
+
onError: (payload) => {
|
|
54
|
+
setError(payload?.message ?? 'Sign-in error');
|
|
55
|
+
},
|
|
56
|
+
onOAuthRedirect: (payload) => {
|
|
57
|
+
if (!payload?.url) {
|
|
58
|
+
setError('OAuth redirect URL is missing');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
window.location.href = payload.url;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
mounted = true;
|
|
65
|
+
})
|
|
66
|
+
.catch((e) => {
|
|
67
|
+
setError(e instanceof Error ? e.message : 'Failed to load widget');
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
if (window.UnoLoginWidgetV2 && mounted) {
|
|
71
|
+
window.UnoLoginWidgetV2.unmount();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [clientId, redirectUri, unoLoginUrl, enabledMethods, locale, theme, debug]);
|
|
75
|
+
return (_jsxs("div", { children: [loginRequired ? (_jsx("p", { style: { marginBottom: 12 }, children: "SSO session was not found. Please sign in with any available method." })) : null, error ? (_jsx("p", { role: "alert", style: { color: '#dc2626', marginBottom: 12 }, children: error })) : null, _jsx("div", { ref: containerRef })] }));
|
|
76
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useAuth } from './context';
|
|
5
|
+
export function UserButton({ signOutUrl = '/api/auth/sign-out' }) {
|
|
6
|
+
const { user, isAuthenticated, refresh } = useAuth();
|
|
7
|
+
const [isBusy, setBusy] = useState(false);
|
|
8
|
+
if (!isAuthenticated || !user) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const displayName = user.firstName || user.username || user.email || user.sub;
|
|
12
|
+
return (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("span", { children: displayName }), _jsx("button", { type: "button", disabled: isBusy, onClick: async () => {
|
|
13
|
+
setBusy(true);
|
|
14
|
+
try {
|
|
15
|
+
await fetch(signOutUrl, { method: 'POST', credentials: 'include' });
|
|
16
|
+
await refresh();
|
|
17
|
+
window.location.href = '/';
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
setBusy(false);
|
|
21
|
+
}
|
|
22
|
+
}, children: isBusy ? 'Signing out...' : 'Sign out' })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function UserProfile(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useAuth } from './context';
|
|
4
|
+
export function UserProfile() {
|
|
5
|
+
const { isLoaded, isAuthenticated, user } = useAuth();
|
|
6
|
+
if (!isLoaded) {
|
|
7
|
+
return _jsx("p", { children: "Loading profile..." });
|
|
8
|
+
}
|
|
9
|
+
if (!isAuthenticated || !user) {
|
|
10
|
+
return _jsx("p", { children: "Please sign in to view your profile." });
|
|
11
|
+
}
|
|
12
|
+
return (_jsxs("section", { style: { display: 'grid', gap: 8 }, children: [_jsx("h2", { style: { margin: 0 }, children: "User Profile" }), _jsxs("div", { children: [_jsx("strong", { children: "ID:" }), " ", user.sub] }), user.email ? (_jsxs("div", { children: [_jsx("strong", { children: "Email:" }), " ", user.email] })) : null, user.username ? (_jsxs("div", { children: [_jsx("strong", { children: "Username:" }), " ", user.username] })) : null, user.role ? (_jsxs("div", { children: [_jsx("strong", { children: "Role:" }), " ", user.role] })) : null, user.organization ? (_jsxs("div", { children: [_jsx("strong", { children: "Organization:" }), " ", user.organization.companyName, " (", user.organization.inn, ")"] })) : null] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { AuthState } from './types';
|
|
3
|
+
interface UnoLoginProviderProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
sessionEndpoint?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function UnoLoginProvider({ children, sessionEndpoint }: UnoLoginProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare function useAuth(): AuthState;
|
|
9
|
+
export declare function useUser(): import("..").UnoLoginUser | null;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
const AuthContext = createContext(null);
|
|
5
|
+
export function UnoLoginProvider({ children, sessionEndpoint = '/api/auth/session' }) {
|
|
6
|
+
const [isLoaded, setLoaded] = useState(false);
|
|
7
|
+
const [isAuthenticated, setAuthenticated] = useState(false);
|
|
8
|
+
const [user, setUser] = useState(null);
|
|
9
|
+
const refresh = useCallback(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const response = await fetch(sessionEndpoint, {
|
|
12
|
+
method: 'GET',
|
|
13
|
+
credentials: 'include',
|
|
14
|
+
cache: 'no-store',
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
setAuthenticated(false);
|
|
18
|
+
setUser(null);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const payload = (await response.json());
|
|
22
|
+
setAuthenticated(payload.authenticated);
|
|
23
|
+
setUser(payload.user ?? null);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
setAuthenticated(false);
|
|
27
|
+
setUser(null);
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
setLoaded(true);
|
|
31
|
+
}
|
|
32
|
+
}, [sessionEndpoint]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
refresh().catch(() => undefined);
|
|
35
|
+
}, [refresh]);
|
|
36
|
+
const value = useMemo(() => ({
|
|
37
|
+
isLoaded,
|
|
38
|
+
isAuthenticated,
|
|
39
|
+
user,
|
|
40
|
+
refresh,
|
|
41
|
+
}), [isLoaded, isAuthenticated, user, refresh]);
|
|
42
|
+
return _jsx(AuthContext.Provider, { value: value, children: children });
|
|
43
|
+
}
|
|
44
|
+
export function useAuth() {
|
|
45
|
+
const context = useContext(AuthContext);
|
|
46
|
+
if (!context) {
|
|
47
|
+
throw new Error('useAuth must be used inside UnoLoginProvider');
|
|
48
|
+
}
|
|
49
|
+
return context;
|
|
50
|
+
}
|
|
51
|
+
export function useUser() {
|
|
52
|
+
return useAuth().user;
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SignInUrlOptions, UnoLoginNextServerConfig, UnoLoginSession } from './types';
|
|
2
|
+
interface CallbackResponseSuccess {
|
|
3
|
+
ok: true;
|
|
4
|
+
session: UnoLoginSession;
|
|
5
|
+
redirectTo: string;
|
|
6
|
+
}
|
|
7
|
+
interface CallbackResponseError {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: string;
|
|
10
|
+
errorDescription?: string;
|
|
11
|
+
}
|
|
12
|
+
export type CallbackResponse = CallbackResponseSuccess | CallbackResponseError;
|
|
13
|
+
export declare class UnoLoginNextServer {
|
|
14
|
+
private readonly coreClient;
|
|
15
|
+
private readonly config;
|
|
16
|
+
constructor(config: UnoLoginNextServerConfig);
|
|
17
|
+
getSignInUrl(options?: SignInUrlOptions): {
|
|
18
|
+
url: string;
|
|
19
|
+
state: string;
|
|
20
|
+
stateCookie: string;
|
|
21
|
+
};
|
|
22
|
+
handleCallback(request: Request): Promise<CallbackResponse>;
|
|
23
|
+
getSession(request: Request): UnoLoginSession | null;
|
|
24
|
+
getSessionFromCookieHeader(cookieHeader: string | null): UnoLoginSession | null;
|
|
25
|
+
requireAuth(request: Request): UnoLoginSession;
|
|
26
|
+
buildSessionCookie(session: UnoLoginSession): string;
|
|
27
|
+
clearSessionCookie(): string;
|
|
28
|
+
clearStateCookie(): string;
|
|
29
|
+
private buildStateCookie;
|
|
30
|
+
private readStateFromRequest;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { UnoLoginClient } from 'unologin-client/server';
|
|
2
|
+
import { decodeSignedValue, encodeSignedValue, randomStateToken } from './crypto';
|
|
3
|
+
import { parseCookieHeader, serializeCookie } from './cookies';
|
|
4
|
+
const DEFAULT_SESSION_COOKIE = '__unologin_session';
|
|
5
|
+
const DEFAULT_STATE_COOKIE = '__unologin_state';
|
|
6
|
+
export class UnoLoginNextServer {
|
|
7
|
+
coreClient;
|
|
8
|
+
config;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = {
|
|
11
|
+
...config,
|
|
12
|
+
sessionCookieName: config.sessionCookieName ?? DEFAULT_SESSION_COOKIE,
|
|
13
|
+
stateCookieName: config.stateCookieName ?? DEFAULT_STATE_COOKIE,
|
|
14
|
+
secureCookies: config.secureCookies ?? true,
|
|
15
|
+
};
|
|
16
|
+
this.coreClient = new UnoLoginClient({
|
|
17
|
+
clientId: config.clientId,
|
|
18
|
+
clientSecret: config.clientSecret,
|
|
19
|
+
redirectUri: config.redirectUri,
|
|
20
|
+
unoLoginUrl: config.unoLoginUrl,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
getSignInUrl(options = {}) {
|
|
24
|
+
const state = randomStateToken();
|
|
25
|
+
const url = this.coreClient.getAuthorizationUrl({
|
|
26
|
+
state,
|
|
27
|
+
prompt: options.prompt,
|
|
28
|
+
role: options.role,
|
|
29
|
+
tenantId: options.tenantId,
|
|
30
|
+
scope: options.scope,
|
|
31
|
+
});
|
|
32
|
+
const statePayload = {
|
|
33
|
+
state,
|
|
34
|
+
redirectTo: options.redirectTo ?? '/',
|
|
35
|
+
};
|
|
36
|
+
const stateCookie = this.buildStateCookie(statePayload);
|
|
37
|
+
return { url, state, stateCookie };
|
|
38
|
+
}
|
|
39
|
+
async handleCallback(request) {
|
|
40
|
+
const requestUrl = new URL(request.url);
|
|
41
|
+
const code = requestUrl.searchParams.get('code');
|
|
42
|
+
const state = requestUrl.searchParams.get('state');
|
|
43
|
+
const error = requestUrl.searchParams.get('error');
|
|
44
|
+
const errorDescription = requestUrl.searchParams.get('error_description');
|
|
45
|
+
if (error) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error,
|
|
49
|
+
errorDescription: errorDescription ?? undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (!code || !state) {
|
|
53
|
+
return { ok: false, error: 'invalid_callback' };
|
|
54
|
+
}
|
|
55
|
+
const authState = this.readStateFromRequest(request);
|
|
56
|
+
if (!authState || authState.state !== state) {
|
|
57
|
+
return { ok: false, error: 'invalid_state' };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const tokenPair = await this.coreClient.exchangeCode(code);
|
|
61
|
+
const user = await this.coreClient.getUserInfo(tokenPair.accessToken);
|
|
62
|
+
const session = {
|
|
63
|
+
accessToken: tokenPair.accessToken,
|
|
64
|
+
refreshToken: tokenPair.refreshToken,
|
|
65
|
+
user,
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
session,
|
|
71
|
+
redirectTo: authState.redirectTo,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { ok: false, error: 'token_exchange_failed' };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
getSession(request) {
|
|
79
|
+
return this.getSessionFromCookieHeader(request.headers.get('cookie'));
|
|
80
|
+
}
|
|
81
|
+
getSessionFromCookieHeader(cookieHeader) {
|
|
82
|
+
const cookies = parseCookieHeader(cookieHeader);
|
|
83
|
+
const raw = cookies[this.config.sessionCookieName];
|
|
84
|
+
if (!raw) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const payload = decodeSignedValue(raw, this.config.cookieSecret);
|
|
88
|
+
if (!payload) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(payload);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
requireAuth(request) {
|
|
99
|
+
const session = this.getSession(request);
|
|
100
|
+
if (!session) {
|
|
101
|
+
throw new Error('Unauthorized');
|
|
102
|
+
}
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
buildSessionCookie(session) {
|
|
106
|
+
const encoded = encodeSignedValue(JSON.stringify(session), this.config.cookieSecret);
|
|
107
|
+
return serializeCookie(this.config.sessionCookieName, encoded, {
|
|
108
|
+
httpOnly: true,
|
|
109
|
+
secure: this.config.secureCookies,
|
|
110
|
+
sameSite: 'lax',
|
|
111
|
+
path: '/',
|
|
112
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
113
|
+
domain: this.config.cookieDomain,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
clearSessionCookie() {
|
|
117
|
+
return serializeCookie(this.config.sessionCookieName, '', {
|
|
118
|
+
httpOnly: true,
|
|
119
|
+
secure: this.config.secureCookies,
|
|
120
|
+
sameSite: 'lax',
|
|
121
|
+
path: '/',
|
|
122
|
+
maxAge: 0,
|
|
123
|
+
domain: this.config.cookieDomain,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
clearStateCookie() {
|
|
127
|
+
return serializeCookie(this.config.stateCookieName, '', {
|
|
128
|
+
httpOnly: true,
|
|
129
|
+
secure: this.config.secureCookies,
|
|
130
|
+
sameSite: 'lax',
|
|
131
|
+
path: '/',
|
|
132
|
+
maxAge: 0,
|
|
133
|
+
domain: this.config.cookieDomain,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
buildStateCookie(payload) {
|
|
137
|
+
const encoded = encodeSignedValue(JSON.stringify(payload), this.config.cookieSecret);
|
|
138
|
+
return serializeCookie(this.config.stateCookieName, encoded, {
|
|
139
|
+
httpOnly: true,
|
|
140
|
+
secure: this.config.secureCookies,
|
|
141
|
+
sameSite: 'lax',
|
|
142
|
+
path: '/',
|
|
143
|
+
maxAge: 60 * 10,
|
|
144
|
+
domain: this.config.cookieDomain,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
readStateFromRequest(request) {
|
|
148
|
+
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
149
|
+
const rawState = cookies[this.config.stateCookieName];
|
|
150
|
+
if (!rawState) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const decoded = decodeSignedValue(rawState, this.config.cookieSecret);
|
|
154
|
+
if (!decoded) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(decoded);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CookieOptions {
|
|
2
|
+
httpOnly?: boolean;
|
|
3
|
+
secure?: boolean;
|
|
4
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
5
|
+
path?: string;
|
|
6
|
+
maxAge?: number;
|
|
7
|
+
domain?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseCookieHeader(headerValue: string | null): Record<string, string>;
|
|
10
|
+
export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function parseCookieHeader(headerValue) {
|
|
2
|
+
if (!headerValue) {
|
|
3
|
+
return {};
|
|
4
|
+
}
|
|
5
|
+
return headerValue
|
|
6
|
+
.split(';')
|
|
7
|
+
.map((chunk) => chunk.trim())
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.reduce((acc, chunk) => {
|
|
10
|
+
const idx = chunk.indexOf('=');
|
|
11
|
+
if (idx < 0) {
|
|
12
|
+
return acc;
|
|
13
|
+
}
|
|
14
|
+
const key = decodeURIComponent(chunk.slice(0, idx).trim());
|
|
15
|
+
const value = decodeURIComponent(chunk.slice(idx + 1));
|
|
16
|
+
acc[key] = value;
|
|
17
|
+
return acc;
|
|
18
|
+
}, {});
|
|
19
|
+
}
|
|
20
|
+
export function serializeCookie(name, value, options = {}) {
|
|
21
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
22
|
+
if (options.maxAge !== undefined) {
|
|
23
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
24
|
+
}
|
|
25
|
+
parts.push(`Path=${options.path ?? '/'}`);
|
|
26
|
+
if (options.domain) {
|
|
27
|
+
parts.push(`Domain=${options.domain}`);
|
|
28
|
+
}
|
|
29
|
+
if (options.httpOnly ?? true) {
|
|
30
|
+
parts.push('HttpOnly');
|
|
31
|
+
}
|
|
32
|
+
if (options.secure ?? true) {
|
|
33
|
+
parts.push('Secure');
|
|
34
|
+
}
|
|
35
|
+
parts.push(`SameSite=${options.sameSite ?? 'Lax'}`);
|
|
36
|
+
return parts.join('; ');
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function toBase64Url(value) {
|
|
3
|
+
return Buffer.from(value, 'utf8').toString('base64url');
|
|
4
|
+
}
|
|
5
|
+
function fromBase64Url(value) {
|
|
6
|
+
return Buffer.from(value, 'base64url').toString('utf8');
|
|
7
|
+
}
|
|
8
|
+
function sign(value, secret) {
|
|
9
|
+
return createHmac('sha256', secret).update(value).digest('base64url');
|
|
10
|
+
}
|
|
11
|
+
export function randomStateToken() {
|
|
12
|
+
return randomBytes(24).toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
export function encodeSignedValue(value, secret) {
|
|
15
|
+
const encoded = toBase64Url(value);
|
|
16
|
+
return `${encoded}.${sign(encoded, secret)}`;
|
|
17
|
+
}
|
|
18
|
+
export function decodeSignedValue(value, secret) {
|
|
19
|
+
const [encoded, mac] = value.split('.');
|
|
20
|
+
if (!encoded || !mac) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const expected = sign(encoded, secret);
|
|
24
|
+
const macBuffer = Buffer.from(mac);
|
|
25
|
+
const expectedBuffer = Buffer.from(expected);
|
|
26
|
+
if (macBuffer.length !== expectedBuffer.length) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!timingSafeEqual(macBuffer, expectedBuffer)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return fromBase64Url(encoded);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UnoLoginNextServerConfig } from './types';
|
|
2
|
+
export interface UnoLoginEnvInput {
|
|
3
|
+
UNOLOGIN_URL?: string;
|
|
4
|
+
UNOLOGIN_CLIENT_ID?: string;
|
|
5
|
+
UNOLOGIN_CLIENT_SECRET?: string;
|
|
6
|
+
UNOLOGIN_REDIRECT_URI?: string;
|
|
7
|
+
UNOLOGIN_COOKIE_SECRET?: string;
|
|
8
|
+
UNOLOGIN_COOKIE_DOMAIN?: string;
|
|
9
|
+
NODE_ENV?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function configFromEnv(env?: UnoLoginEnvInput): UnoLoginNextServerConfig;
|