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,7 +1,7 @@
[package]
name = "recorder"
version = "0.1.0"
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
@@ -52,7 +52,7 @@ reqwest = { version = "0.12", features = [
thiserror = "2"
rss = "2"
bytes = "1.9"
itertools = "0.13.0"
itertools = "0.14"
url = "2.5"
fancy-regex = "0.14"
regex = "1.11"
@@ -92,7 +92,7 @@ async-graphql = { version = "7.0.13", features = [] }
async-graphql-axum = "7.0.13"
fastrand = "2.3.0"
seaography = "1.1.2"
quirks_path = "0.1.0"
quirks_path = "0.1.1"
base64 = "0.22.1"
tower = "0.5.2"
axum-extra = "0.10.0"
@@ -112,6 +112,7 @@ http-cache = { version = "0.20.0", features = [
], default-features = false }
http-cache-semantics = "2.1.0"
dotenv = "0.15.0"
nom = "8.0.0"
[dev-dependencies]
serial_test = "3"

View File

@@ -1,29 +1,33 @@
{
"name": "recorder",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"preview": "rsbuild preview"
},
"dependencies": {
"@abraham/reflection": "^0.12.0",
"@graphiql/react": "^0.28.2",
"@graphiql/toolkit": "^0.11.1",
"@konobangu/design-system": "workspace:*",
"@konobangu/tailwind-config": "workspace:*",
"@outposts/injection-js": "^2.5.1",
"@tanstack/react-router": "^1.95.6",
"@tanstack/router-devtools": "^1.95.6",
"graphiql": "^3.8.3",
"graphql-ws": "^5.16.2",
"oidc-client-ts": "^3.1.0",
"observable-hooks": "^4.2.4",
"oidc-client-rx": "0.1.0-alpha.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-oidc-context": "^3.2.0"
"rxjs": "^7.8.1"
},
"devDependencies": {
"@konobangu/typescript-config": "workspace:*",
"@rsbuild/core": "^1.1.13",
"@rsbuild/plugin-react": "^1.1.0",
"@rsbuild/core": "1.1.3",
"@rsbuild/plugin-react": "^1.1.1",
"@tanstack/router-plugin": "^1.95.6",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",

View File

@@ -2,4 +2,4 @@ export default {
plugins: {
tailwindcss: {},
},
}
}

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>
);
}

View File

@@ -7,6 +7,8 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true