feat: switch to oidc-client-rx

This commit is contained in:
2025-02-23 23:54:09 +08:00
parent 027112db9a
commit ae40a3a7f8
17 changed files with 1097 additions and 745 deletions

View File

@@ -1,53 +0,0 @@
import { RouterProvider, createRouter } from '@tanstack/react-router';
import type { UserManager } from 'oidc-client-ts';
import { useMemo } from 'react';
import { AuthProvider, useAuth } from 'react-oidc-context';
import { buildUserManager } from '../auth/config';
import { routeTree } from '../routeTree.gen';
// Set up a Router instance
const router = createRouter({
routeTree,
basepath: '/api/playground',
defaultPreload: 'intent',
context: {
isAuthenticated: process.env.AUTH_TYPE === 'basic',
auth: undefined!,
userManager: undefined!,
},
});
// Register things for typesafety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
const AppWithBasicAuth = () => {
return <RouterProvider router={router} />;
};
const AppWithOidcAuthInner = ({
userManager,
}: { userManager: UserManager }) => {
const auth = useAuth();
return (
<RouterProvider
router={router}
context={{ isAuthenticated: auth.isAuthenticated, auth, userManager }}
/>
);
};
const AppWithOidcAuth = () => {
const userManager = useMemo(() => buildUserManager(), []);
return (
<AuthProvider userManager={userManager}>
<AppWithOidcAuthInner userManager={userManager} />
</AuthProvider>
);
};
export const App =
process.env.AUTH_TYPE === 'oidc' ? AppWithOidcAuth : AppWithBasicAuth;

View File

@@ -1,31 +1,42 @@
import { type OidcClientSettings, UserManager } from 'oidc-client-ts';
import { InjectionToken } from '@outposts/injection-js';
import {
type EventTypes,
LogLevel,
type OpenIdConfiguration,
} from 'oidc-client-rx';
export const PostLoginRedirectUriKey = 'post_login_redirect_uri';
export const isBasicAuth = process.env.AUTH_TYPE === 'basic';
export function buildOidcConfig(): OidcClientSettings {
export function buildOidcConfig(): OpenIdConfiguration {
const origin = window.location.origin;
const resource = process.env.OIDC_AUDIENCE!;
return {
authority: process.env.OIDC_ISSUER!,
client_id: process.env.OIDC_CLIENT_ID!,
client_secret: process.env.OIDC_CLIENT_SECRET!,
redirect_uri: `${origin}/api/playground/oidc/callback`,
disablePKCE: false,
scope: `openid profile email ${process.env.OIDC_EXTRA_SCOPES}`,
response_type: 'code',
resource,
post_logout_redirect_uri: `${origin}/api/playground`,
extraQueryParams: {
redirectUrl: `${origin}/api/playground/oidc/callback`,
postLogoutRedirectUri: `${origin}/api/playground`,
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scope: process.env.OIDC_EXTRA_SCOPES
? `openid profile email offline_access ${process.env.OIDC_EXTRA_SCOPES}`
: 'openid profile email offline_access',
triggerAuthorizationResultEvent: true,
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
logLevel: LogLevel.Debug,
autoUserInfo: !resource,
renewUserInfoAfterTokenRenew: !resource,
customParamsAuthRequest: {
prompt: 'consent',
resource,
},
extraTokenParams: {
customParamsRefreshTokenRequest: {
resource,
},
customParamsCodeRequest: {
resource,
},
};
}
export function buildUserManager(): UserManager {
return new UserManager(buildOidcConfig());
}

View File

@@ -0,0 +1,41 @@
import type { Observable } from '@graphiql/toolkit';
import { InjectionToken, inject } from '@outposts/injection-js';
import {
type AuthFeature,
EventTypes,
PublicEventsService,
} from 'oidc-client-rx';
import { filter, shareReplay } from 'rxjs';
export type CheckAuthResultEventType =
| { type: EventTypes.CheckingAuthFinished }
| {
type: EventTypes.CheckingAuthFinishedWithError;
value: string;
};
export const CHECK_AUTH_RESULT_EVENT = new InjectionToken<
Observable<CheckAuthResultEventType>
>('CHECK_AUTH_RESULT_EVENT');
export function withCheckAuthResultEvent(): AuthFeature {
return {
ɵproviders: [
{
provide: CHECK_AUTH_RESULT_EVENT,
useFactory: () => {
const publishEventService = inject(PublicEventsService);
return publishEventService.registerForEvents().pipe(
filter(
(e) =>
e.type === EventTypes.CheckingAuthFinishedWithError ||
e.type === EventTypes.CheckingAuthFinished
),
shareReplay(1)
);
},
deps: [PublicEventsService],
},
],
};
}

View File

@@ -1,21 +1,19 @@
import type { ParsedLocation } from '@tanstack/react-router';
import { runInInjectionContext } from '@outposts/injection-js';
import { autoLoginPartialRoutesGuard } from 'oidc-client-rx';
import { firstValueFrom } from 'rxjs';
import type { RouterContext } from '../controllers/__root';
import { PostLoginRedirectUriKey } from './config';
export const beforeLoadGuard = async ({
context,
location,
// biome-ignore lint/complexity/noBannedTypes: <explanation>
}: { context: RouterContext; location: ParsedLocation<{}> }) => {
}: { context: RouterContext }) => {
if (!context.isAuthenticated) {
// TODO: FIXME
const user = await context.userManager.getUser();
if (!user) {
try {
sessionStorage.setItem(PostLoginRedirectUriKey, location.href);
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
} catch {}
throw await context.auth.signinRedirect();
const guard$ = runInInjectionContext(context.injector, () =>
autoLoginPartialRoutesGuard()
);
const isAuthenticated = await firstValueFrom(guard$);
if (!isAuthenticated) {
throw !isAuthenticated;
}
}
};

View File

@@ -0,0 +1,52 @@
import { useObservableEagerState, useObservableState } from 'observable-hooks';
import {
InjectorContextVoidInjector,
useOidcClient,
} from 'oidc-client-rx/adapters/react';
import { useMemo } from 'react';
import { NEVER, type Observable, of } from 'rxjs';
import { isBasicAuth } from './config';
import {
CHECK_AUTH_RESULT_EVENT,
type CheckAuthResultEventType,
} from './event';
const BASIC_AUTH_IS_AUTHENTICATED$ = of({
isAuthenticated: true,
allConfigsAuthenticated: [],
});
const BASIC_AUTH_USER_DATA$ = of({
userData: {},
allUserData: [],
});
export function useAuth() {
const { oidcSecurityService, injector } = isBasicAuth
? { oidcSecurityService: undefined, injector: InjectorContextVoidInjector }
: // biome-ignore lint/correctness/useHookAtTopLevel: <explanation>
useOidcClient();
const { isAuthenticated } = useObservableEagerState(
oidcSecurityService?.isAuthenticated$ ?? BASIC_AUTH_IS_AUTHENTICATED$
);
const { userData } = useObservableEagerState(
oidcSecurityService?.userData$ ?? BASIC_AUTH_USER_DATA$
);
const checkAuthResultEvent = useObservableState(
useMemo(
() => (isBasicAuth ? NEVER : injector.get(CHECK_AUTH_RESULT_EVENT)),
[injector]
) as Observable<CheckAuthResultEventType>
);
return {
oidcSecurityService,
isAuthenticated,
userData,
injector,
checkAuthResultEvent,
};
}

View File

@@ -1,22 +1,22 @@
import type { Injector } from '@outposts/injection-js';
import {
Link,
// Link,
Outlet,
createRootRouteWithContext,
} from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import type { UserManager } from 'oidc-client-ts';
import type { AuthContextProps } from 'react-oidc-context';
import type { OidcSecurityService } from 'oidc-client-rx';
export type RouterContext =
| {
isAuthenticated: false;
auth: AuthContextProps;
userManager: UserManager;
injector: Injector;
oidcSecurityService: OidcSecurityService;
}
| {
isAuthenticated: true;
auth?: AuthContextProps;
userManager?: UserManager;
injector?: Injector;
oidcSecurityService?: OidcSecurityService;
};
export const Route = createRootRouteWithContext<RouterContext>()({

View File

@@ -1,10 +1,11 @@
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { type Fetcher, createGraphiQLFetcher } from '@graphiql/toolkit';
import { createFileRoute } from '@tanstack/react-router';
import GraphiQL from 'graphiql';
import { useMemo } from 'react';
import { useAuth } from 'react-oidc-context';
import { beforeLoadGuard } from '../../auth/guard';
import 'graphiql/graphiql.css';
import { firstValueFrom } from 'rxjs';
import { useAuth } from '../../auth/hooks';
export const Route = createFileRoute('/graphql/')({
component: RouteComponent,
@@ -12,19 +13,23 @@ export const Route = createFileRoute('/graphql/')({
});
function RouteComponent() {
const auth = useAuth();
const { oidcSecurityService } = useAuth();
const fetcher = useMemo(
() =>
createGraphiQLFetcher({
(): Fetcher => async (props) => {
const accessToken = oidcSecurityService
? await firstValueFrom(oidcSecurityService.getAccessToken())
: undefined;
return createGraphiQLFetcher({
url: '/api/graphql',
headers: auth?.user?.access_token
headers: accessToken
? {
Authorization: `Bearer ${auth.user.access_token}`,
Authorization: `Bearer ${accessToken}`,
}
: undefined,
}),
[auth]
})(props);
},
[oidcSecurityService]
);
return <GraphiQL fetcher={fetcher} className="h-svh" />;

View File

@@ -1,12 +1,11 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { useEffect } from 'react';
import { useAuth } from 'react-oidc-context';
import { PostLoginRedirectUriKey } from '../../auth/config';
import { EventTypes } from 'oidc-client-rx';
import { useAuth } from '../../auth/hooks';
export const Route = createFileRoute('/oidc/callback')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (!context.auth) {
if (!context.oidcSecurityService) {
throw redirect({
to: '/',
});
@@ -17,26 +16,17 @@ export const Route = createFileRoute('/oidc/callback')({
function RouteComponent() {
const auth = useAuth();
useEffect(() => {
if (!auth?.isLoading && auth?.isAuthenticated) {
try {
const redirectUri = sessionStorage.getItem(PostLoginRedirectUriKey);
if (redirectUri) {
history.replaceState(null, '', redirectUri);
}
// biome-ignore lint/suspicious/noEmptyBlockStatements: <explanation>
} catch {}
}
}, [auth]);
if (auth?.isLoading) {
if (!auth.checkAuthResultEvent) {
return <div>Loading...</div>;
}
return (
<div>
OpenID Connect Auth Callback Result:{' '}
{auth.error ? auth.error?.message : 'unknown'}
OpenID Connect Auth Callback:{' '}
{auth.checkAuthResultEvent?.type ===
EventTypes.CheckingAuthFinishedWithError
? auth.checkAuthResultEvent.value
: 'success'}
</div>
);
}

View File

@@ -1,3 +1,6 @@
/**
* @TODO: rewrite with nom
*/
use std::borrow::Cow;
use itertools::Itertools;
@@ -322,7 +325,7 @@ pub fn parse_episode_meta_from_raw_name(s: &str) -> color_eyre::eyre::Result<Raw
#[cfg(test)]
mod tests {
use super::{parse_episode_meta_from_raw_name, RawEpisodeMeta};
use super::{RawEpisodeMeta, parse_episode_meta_from_raw_name};
fn test_raw_ep_parser_case(raw_name: &str, expected: &str) {
let expected: Option<RawEpisodeMeta> = serde_json::from_str(expected).unwrap_or_default();

View File

@@ -1,15 +1,96 @@
import '@abraham/reflection';
import { type Injector, ReflectiveInjector } from '@outposts/injection-js';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import {
OidcSecurityService,
provideAuth,
withDefaultFeatures,
} from 'oidc-client-rx';
import {
InjectorContextVoidInjector,
InjectorProvider,
} from 'oidc-client-rx/adapters/react';
import { withTanstackRouter } from 'oidc-client-rx/adapters/tanstack-router';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './app';
import { buildOidcConfig, isBasicAuth } from './auth/config';
import { withCheckAuthResultEvent } from './auth/event';
import { useAuth } from './auth/hooks';
import { routeTree } from './routeTree.gen';
import './main.css';
const router = createRouter({
routeTree,
basepath: '/api/playground',
defaultPreload: 'intent',
context: {
isAuthenticated: isBasicAuth,
injector: InjectorContextVoidInjector,
oidcSecurityService: {} as OidcSecurityService,
},
});
// Register things for typesafety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
const injector: Injector = isBasicAuth
? ReflectiveInjector.resolveAndCreate([])
: ReflectiveInjector.resolveAndCreate(
provideAuth(
{
config: buildOidcConfig(),
},
withDefaultFeatures({
router: { enabled: false },
securityStorage: { type: 'local-storage' },
}),
withTanstackRouter(router),
withCheckAuthResultEvent()
)
);
// if needed, check when init
let oidcSecurityService: OidcSecurityService | undefined;
if (!isBasicAuth) {
oidcSecurityService = injector.get(OidcSecurityService);
oidcSecurityService.checkAuth().subscribe();
}
const AppWithBasicAuth = () => {
return <RouterProvider router={router} />;
};
const AppWithOidcAuth = () => {
const { isAuthenticated, oidcSecurityService, injector } = useAuth();
return (
<RouterProvider
router={router}
context={{
isAuthenticated,
oidcSecurityService,
injector,
}}
/>
);
};
const App = isBasicAuth ? AppWithBasicAuth : AppWithOidcAuth;
const rootEl = document.getElementById('root');
if (rootEl) {
rootEl.classList.add('min-h-svh');
const root = ReactDOM.createRoot(rootEl);
root.render(
<React.StrictMode>
<App />
<InjectorProvider injector={injector}>
<App />
</InjectorProvider>
</React.StrictMode>
);
}