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.

5.2 KiB

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)

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)

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)

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)

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.