Skip to content

Commit a474aec

Browse files
committed
feat: add OIDC support
1 parent 8654b5b commit a474aec

23 files changed

Lines changed: 872 additions & 81 deletions

File tree

demo/backend/nexus.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import { timers } from '@bitlerjs/nexus-timers';
1111
import { todos } from './src/extension.js';
1212

1313
const config = defineConfig({
14+
oidc: process.env.OIDC_ISSUER_URL
15+
? {
16+
issuerUrl: process.env.OIDC_ISSUER_URL,
17+
clientId: process.env.OIDC_CLIENT_ID,
18+
}
19+
: undefined,
1420
extensions: [
1521
defineExtension(todos, {}),
1622
defineExtension(typescript, {}),

packages/nexus-client-ws/src/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Socket } from './socket/socket.js';
77

88
type ClientOptions = {
99
url: string;
10-
token: string;
10+
headers?: Record<string, string>;
1111
};
1212

1313
type ClientEvents = {
@@ -23,7 +23,7 @@ class Client<TSchema extends ServerDefinition = ServerDefinition> extends EventE
2323

2424
constructor(options: ClientOptions) {
2525
super();
26-
this.#socket = new Socket({ url: options.url, token: options.token });
26+
this.#socket = new Socket({ url: options.url, headers: options.headers });
2727
this.#tasks = new Tasks({ socket: this.#socket });
2828
this.#events = new Events({ socket: this.#socket });
2929

packages/nexus-client-ws/src/socket/socket.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createRequest, type RequestPayload, RequestResponse, type RequestType }
33

44
type SocketOptions = {
55
url: string;
6-
token: string;
6+
headers?: Record<string, string>;
77
};
88

99
type SocketEvents = {
@@ -13,6 +13,8 @@ type SocketEvents = {
1313
close: () => void;
1414
};
1515

16+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
17+
1618
class Socket extends EventEmitter<SocketEvents> {
1719
#options: SocketOptions;
1820
#socket?: Promise<WebSocket>;
@@ -39,14 +41,12 @@ class Socket extends EventEmitter<SocketEvents> {
3941
}
4042
};
4143
socket.addEventListener('message', authListener);
42-
socket.addEventListener('open', async () => {
43-
socket.send(JSON.stringify({ type: 'authenticate', payload: { token: this.#options.token } }));
44-
});
4544

46-
socket.addEventListener('close', () => {
45+
socket.addEventListener('close', async () => {
4746
this.#socket = undefined;
4847
this.emit('close');
4948
if (!this.#closed) {
49+
await sleep(3000);
5050
resolve(this.#setup());
5151
}
5252
});
@@ -58,6 +58,10 @@ class Socket extends EventEmitter<SocketEvents> {
5858
socket.addEventListener('message', ({ data }) => {
5959
this.emit('message', JSON.parse(data));
6060
});
61+
62+
socket.addEventListener('open', async () => {
63+
socket.send(JSON.stringify({ type: 'authenticate', payload: { headers: this.#options.headers || {} } }));
64+
});
6165
});
6266

6367
public getSocket = async () => {
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/dist/
2+
/node_modules/
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@bitlerjs/nexus-react-login",
3+
"version": "1.0.0",
4+
"main": "dist/exports.js",
5+
"type": "module",
6+
"files": [
7+
"dist"
8+
],
9+
"scripts": {
10+
"build": "tsc --build",
11+
"dev": "tsc --build --watch",
12+
"generate-types": "tsx src/scripts/generate-types.ts"
13+
},
14+
"devDependencies": {
15+
"@bitlerjs/nexus": "workspace:*",
16+
"@bitlerjs/nexus-config": "workspace:*",
17+
"@types/react": "^18.3.18",
18+
"react": "^18.3.1",
19+
"tsx": "^4.19.2",
20+
"typescript": "^5.7.2"
21+
},
22+
"dependencies": {
23+
"@bitlerjs/nexus-react-ws": "workspace:*",
24+
"oidc-client-ts": "^3.1.0"
25+
}
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { WebLoginProvider } from './provider/provider.js';
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2+
import { User, UserManager, WebStorageStateStore } from 'oidc-client-ts';
3+
import { LoginProvider } from '@bitlerjs/nexus-react-ws';
4+
5+
type OIDCLoginOptions = {
6+
authority: string;
7+
clientId: string;
8+
onLogout?: () => void;
9+
error?: unknown;
10+
};
11+
12+
const useCreateLogin = (options: OIDCLoginOptions) => {
13+
const userManager = useMemo(
14+
() =>
15+
new UserManager({
16+
authority: options.authority,
17+
client_id: options.clientId,
18+
redirect_uri: location.href,
19+
revokeTokenTypes: ['refresh_token'],
20+
scope: 'openid profile email offline_access',
21+
automaticSilentRenew: false,
22+
response_type: 'code',
23+
userStore: new WebStorageStateStore({ store: localStorage }),
24+
}),
25+
[],
26+
);
27+
const [state, setState] = useState<'pending' | 'logged-out' | 'logged-in'>('pending');
28+
const [user, setUser] = useState<User>();
29+
30+
useEffect(() => {
31+
const loadUserFromStorage = async () => {
32+
try {
33+
const storedUser = await userManager.getUser();
34+
if (storedUser) {
35+
setUser(storedUser);
36+
setState('logged-in');
37+
} else {
38+
setState('logged-out');
39+
}
40+
} catch (error) {
41+
console.error('Error loading user from storage:', error);
42+
}
43+
};
44+
45+
loadUserFromStorage();
46+
47+
userManager.events.addAccessTokenExpired(() => {
48+
console.log('Access token expired');
49+
userManager.signoutRedirect();
50+
});
51+
52+
userManager.events.addUserLoaded(() => {
53+
setState('logged-in');
54+
});
55+
56+
userManager.events.addUserUnloaded(() => {
57+
setState('logged-out');
58+
options.onLogout?.();
59+
});
60+
61+
userManager.events.addSilentRenewError((error) => {
62+
console.error('Silent renew error', error);
63+
});
64+
65+
userManager.events.addAccessTokenExpiring(() => {
66+
console.log('Access token expiring');
67+
userManager.signinSilent();
68+
});
69+
}, [userManager]);
70+
71+
const login = useCallback(async () => {
72+
try {
73+
await userManager.signinRedirect();
74+
} catch (error) {
75+
console.error('Login error:', error);
76+
}
77+
}, [userManager]);
78+
79+
const logout = useCallback(async () => {
80+
try {
81+
console.log('Logout');
82+
options.onLogout?.();
83+
await userManager.signoutRedirect();
84+
} catch (error) {
85+
console.error('Logout error:', error);
86+
}
87+
}, [userManager]);
88+
89+
useEffect(() => {
90+
if (state !== 'logged-out') {
91+
return;
92+
}
93+
const currentUrl = new URL(window.location.href);
94+
if (currentUrl.searchParams.has('code')) {
95+
const run = async () => {
96+
const user = await userManager.signinCallback(location.href);
97+
setUser(user);
98+
setState('logged-in');
99+
const nextUrl = new URL(location.href);
100+
nextUrl.searchParams.delete('code');
101+
nextUrl.searchParams.delete('state');
102+
window.history.replaceState({}, '', nextUrl.toString());
103+
};
104+
run();
105+
return;
106+
}
107+
login();
108+
}, [state, login]);
109+
110+
return {
111+
state,
112+
accessToken: user?.access_token,
113+
login,
114+
logout,
115+
};
116+
};
117+
118+
type OIDCLoginProviderProps = OIDCLoginOptions & {
119+
url: string;
120+
children: React.ReactNode;
121+
loading?: React.ReactNode;
122+
};
123+
124+
const OIDCLoginProvider = ({ children, url, loading, ...rest }: OIDCLoginProviderProps) => {
125+
const login = useCreateLogin(rest);
126+
127+
if (login.state !== 'logged-in') {
128+
return loading || null;
129+
}
130+
131+
return (
132+
<LoginProvider accessToken={login.accessToken} url={url} onLogout={login.logout}>
133+
{children}
134+
</LoginProvider>
135+
);
136+
};
137+
138+
export { OIDCLoginProvider };
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import React, { ComponentType, useCallback, useEffect, useState } from 'react';
2+
import { OIDCLoginProvider } from './oidc/oidc.js';
3+
import { LoginProvider } from '@bitlerjs/nexus-react-ws';
4+
5+
type WebLoginProviderProps = {
6+
url: string;
7+
clientId?: string;
8+
loading?: React.ReactNode;
9+
error?: ComponentType<{ error: unknown; logout: () => void }>;
10+
children: React.ReactNode;
11+
onLogout?: () => void;
12+
};
13+
14+
type ServerConfig = {
15+
oidc?: {
16+
issuerUrl: string;
17+
clientId?: string;
18+
};
19+
};
20+
21+
const WebLoginProvider = ({ children, clientId, onLogout, loading, url, error: ErrorView }: WebLoginProviderProps) => {
22+
const [config, setConfig] = useState<ServerConfig | undefined>(undefined);
23+
const [error, setError] = useState<unknown | undefined>(undefined);
24+
25+
const updateConfig = useCallback(async () => {
26+
try {
27+
const configUrl = new URL('api/config', url).toString();
28+
const response = await fetch(configUrl);
29+
if (!response.ok) {
30+
throw new Error('Failed to fetch server config');
31+
}
32+
const data = await response.json();
33+
setConfig(data);
34+
} catch (error) {
35+
setError(error);
36+
}
37+
}, [url]);
38+
39+
useEffect(() => {
40+
updateConfig();
41+
}, [updateConfig]);
42+
43+
const handleLogout = useCallback(() => {
44+
onLogout?.();
45+
}, [onLogout]);
46+
47+
if (error) {
48+
return ErrorView ? <ErrorView logout={handleLogout} error={error} /> : null;
49+
}
50+
51+
if (!config) {
52+
return loading || null;
53+
}
54+
55+
if (config.oidc) {
56+
const clientIdToUse = config.oidc.clientId || clientId;
57+
if (!clientIdToUse) {
58+
if (!ErrorView) {
59+
throw new Error('Missing client ID');
60+
}
61+
return <ErrorView logout={handleLogout} error={new Error('Missing client ID')} />;
62+
}
63+
return (
64+
<OIDCLoginProvider
65+
loading={loading}
66+
url={url}
67+
clientId={clientIdToUse}
68+
authority={config.oidc.issuerUrl}
69+
onLogout={onLogout}
70+
>
71+
{children}
72+
</OIDCLoginProvider>
73+
);
74+
} else {
75+
return (
76+
<LoginProvider url={url} onLogout={handleLogout}>
77+
{children}
78+
</LoginProvider>
79+
);
80+
}
81+
};
82+
83+
export { WebLoginProvider };
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "@bitlerjs/nexus-config",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"jsx": "react"
6+
},
7+
"include": [
8+
"src/**/*"
9+
]
10+
}

packages/nexus-react-ws/src/exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from './client/client.js';
33
export * from './provider/provider.js';
44
export * from './conversation/conversation.js';
55
export * from './configs/configs.js';
6+
export * from './login/login.js';
67
export * from '@bitlerjs/nexus-client-ws';

0 commit comments

Comments
 (0)