|
|
|
|
@ -0,0 +1,154 @@
|
|
|
|
|
# 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.
|