You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

155 lines
5.2 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# STEP 03 Adapt GraphQL context, authorization, and docs to SwissOID sessions
Aligned the GraphQL layer with the session-based flow exposed by `swissoid-back`, eliminating the old JWT token helpers and ensuring resolvers consume the shared session service.
## Updated types (`biblio-stats-graphql/src/generalTypes.ts`)
```ts
import { ActionOutcomeError, ActionOutcomeFail, ActionOutcomeSuccess, ActionStatus, UUIDProp } from 'graphql-knifey';
import type { Request } from 'express';
import type { CookieManager, SwissOIDSessionService } from 'swissoid-back';
import UserService from './services/UserService';
import StatService from './services/StatService';
import type { AppConfig } from './config/appConfig';
export type BiblioStatsGraphQLContext = {
statService: StatService;
userService: UserService;
coreToDomain: (core: string) => string;
sessionService?: SwissOIDSessionService;
cookieManager?: CookieManager;
appConfig?: AppConfig;
logger?: { log: (...args: any[]) => void };
token?: string;
req?: Request & { cookies?: Record<string, string> };
user?: {
UUID?: string;
email?: string;
username?: string;
sub?: string;
};
};
```
## Session-backed authorization (`biblio-stats-graphql/src/utils/authorize.ts`)
```ts
import type { SessionData } from 'swissoid-back';
import { BiblioStatsGraphQLContext, UserProp } from '../generalTypes';
import { getFailOutcomeFromError } from 'graphql-knifey';
import { ActionOutcomeFail, ActionOutcomeForbidden, ActionStatus, GqlResolversContextParams } from 'graphql-knifey/build/src/generalTypes';
const unauthenticatedOutcome: ActionOutcomeFail<'UNAUTHENTICATED'> = {
status: ActionStatus.fail,
code: 'UNAUTHENTICATED',
message: 'Authentication required',
};
function buildUserFromSession(session: SessionData) {
return {
UUID: session.sub,
email: session.email,
username: session.metadata?.username ?? session.name ?? session.email,
sub: session.sub,
};
}
async function resolveSession(context: GqlResolversContextParams<BiblioStatsGraphQLContext>) {
const { sessionService, cookieManager, req } = context;
if (!sessionService || !req) {
return null;
}
const cookieName = context.appConfig?.sessionCookieName || 'sid';
let sessionId = cookieManager?.getSessionId?.(req) || req.cookies?.[cookieName];
if (!sessionId) {
const rawCookies = req.headers?.cookie ?? req.headers?.Cookie;
const cookieHeader = Array.isArray(rawCookies) ? rawCookies.join(';') : rawCookies;
if (cookieHeader) {
sessionId = cookieHeader
.split(';')
.map((cookie) => cookie.trim().split('=') as [string, string])
.find(([name]) => name === cookieName)?.[1];
}
}
if (!sessionId) {
return null;
}
const session = await sessionService.getSession(sessionId);
return session ?? null;
}
export async function authorized<T extends {}>(
context: GqlResolversContextParams<BiblioStatsGraphQLContext>,
input: T
): Promise<{ shouldReturn: true; value: ActionOutcomeForbidden | ActionOutcomeFail<'ERROR'> | ActionOutcomeFail<'UNAUTHENTICATED'> } | { shouldReturn: false; value: UserProp & T }>
{
try {
const session = await resolveSession(context);
if (!session) {
return { shouldReturn: true, value: unauthenticatedOutcome };
}
const props = {
...input,
user: buildUserFromSession(session),
};
context.user = props.user;
const isAllowed = await context.userService.existsOrRegisterAndIfExistsIsAllowed({ user: props.user });
if (!isAllowed) {
return {
shouldReturn: true,
value: {
status: ActionStatus.fail,
code: 'FORBIDDEN',
message: "You don't have permission to perform this request",
},
};
}
return {
shouldReturn: false,
value: props,
};
} catch (err) {
return {
shouldReturn: true,
value: getFailOutcomeFromError(err as Error),
};
}
}
```
## Resolver integration (`biblio-stats-graphql/src/graphql/resolvers/index.ts`)
```ts
import { GQLResolverDict, getFailOutcomeFromError } from 'graphql-knifey';
// ...
registerUser: async (_, __, context) => {
try {
const authResult = await authorized(context, {});
if (authResult.shouldReturn) return authResult.value;
return await context.userService.registerUser(authResult.value);
} catch (err) {
return getFailOutcomeFromError(err as Error);
}
},
```
## Documentation (`biblio-stats-graphql/README.md`)
```md
Authentication is handled by the shared SwissOID backend (`swissoid-back`). The service mounts the reusable OIDC routes so `/login`, `/oidc/callback`, `/auth/status`, and `/auth/logout` follow the exact same flow as cronide-user. Sessions are stored in Redis and materialised as HttpOnly cookies on the relying party domain.
Resolvers read the active session via `sessionService` and `cookieManager` from `swissoid-back`. When a valid session cookie is present the user UUID/email is injected into the GraphQL context and used for downstream authorization.
```
Legacy JWT helpers were deleted (`src/loaders/tokenAuthService.ts`, `src/middleware/swissoidAuth.ts`, `src/setupAuth.ts`, and related middleware wrappers) because the SwissOID middleware now covers the full flow.