From ecd06cb1c6485025607f0d17390cf672ec336b0f Mon Sep 17 00:00:00 2001 From: Donovan Ellison Date: Thu, 26 Jun 2025 14:15:59 -0700 Subject: [PATCH 1/2] chore: adding .tool-versions to resolve npm error message --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..9e345fac --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 20.18.1 \ No newline at end of file From a9474f4e7c9ead238e050591122d21155255c92f Mon Sep 17 00:00:00 2001 From: Donovan Ellison Date: Thu, 26 Jun 2025 14:29:34 -0700 Subject: [PATCH 2/2] feat: adding authz providers to OIDC plugin --- plugins/oidc/README.md | 293 +++++++++++++++++- .../migrations/1_oidc_users_and_groups.sql | 38 +++ .../oidc/src/OIDCGroupBasedAuthzProvider.ts | 50 +++ .../src/OIDCGroupBasedReviewerProvider.ts | 164 ++++++++++ plugins/oidc/src/OIDCIdentityProvider.ts | 244 +++++++++++++++ plugins/oidc/src/OIDCTeamProvider.ts | 144 +++++++++ plugins/oidc/src/OIDCUserProvider.ts | 39 +++ plugins/oidc/src/OIDCUserStore.ts | 260 ++++++++++++++++ plugins/oidc/src/index.ts | 247 ++------------- plugins/oidc/src/util.ts | 68 +++- 10 files changed, 1311 insertions(+), 236 deletions(-) create mode 100644 plugins/oidc/migrations/1_oidc_users_and_groups.sql create mode 100644 plugins/oidc/src/OIDCGroupBasedAuthzProvider.ts create mode 100644 plugins/oidc/src/OIDCGroupBasedReviewerProvider.ts create mode 100644 plugins/oidc/src/OIDCIdentityProvider.ts create mode 100644 plugins/oidc/src/OIDCTeamProvider.ts create mode 100644 plugins/oidc/src/OIDCUserProvider.ts create mode 100644 plugins/oidc/src/OIDCUserStore.ts diff --git a/plugins/oidc/README.md b/plugins/oidc/README.md index 65931390..946cf627 100644 --- a/plugins/oidc/README.md +++ b/plugins/oidc/README.md @@ -1,8 +1,14 @@ -# README +# OIDC Plugin -Pack with OIDC provider, i.e. SSO login via a third party OIDC compatible provider (such as Okta). +OIDC provider for authentication and authorization via OIDC-compatible providers (such as Okta, Azure AD, etc.). -## Configuration +## Features + +- **Authentication**: OIDC-based identity provider for SSO login +- **Authorization**: Group-based authorization providers for roles, teams, and reviewers +- **User Storage**: Database-backed user information storage from authentication for long-term reference and usage + +## Installation Add the @gram/oidc package to your config. @@ -10,36 +16,293 @@ Add the @gram/oidc package to your config. npm -w config i @gram/oidc ``` -Then modify your configuration by adding the OIDCIdentityProvider. +## Database Migration + +The OIDC plugin requires database tables for storing user information and group mappings. Include the OIDC migrations in your configuration: + +```ts +import { OIDCMigrations } from "@gram/oidc"; + +export const defaultConfig: GramConfiguration = { + // ... + additionalMigrations: [OIDCMigrations], + // ... +}; +``` + +## Configuration + +### Basic Authentication Only + +For basic OIDC authentication without authorization providers: ```ts import { EnvSecret } from "@gram/core/dist/config/EnvSecret.js"; import { OIDCIdentityProvider } from "@gram/oidc"; +// ... +bootstrapProviders: async function (dal: DataAccessLayer) { + const oidc = new OIDCIdentityProvider( + dal, // DataAccessLayer instance - required first parameter + "https:///.well-known/openid-configuration", + new EnvSecret("OIDC_CLIENT_ID"), + new EnvSecret("OIDC_CLIENT_SECRET"), + new EnvSecret("OIDC_SESSION_SECRET"), + "email", // field to use for identity sub + "oidc", // provider key + true, // capture group claims + "groups" // name of groups claim + ); + + return { + // ... + identityProviders: [oidc], + } +} +``` + +### Full OIDC with Authorization Providers + +For complete OIDC authentication and authorization: + +```ts +import { EnvSecret } from "@gram/core/dist/config/EnvSecret.js"; +import { Role } from "@gram/core/dist/auth/models/Role.js"; +import { + OIDCIdentityProvider, + OIDCGroupBasedAuthzProvider, + OIDCUserProvider, + OIDCTeamProvider, + OIDCGroupBasedReviewerProvider +} from "@gram/oidc"; // ... -bootstrapProviders: async function () { +bootstrapProviders: async function (dal: DataAccessLayer) { + // Authentication provider const oidc = new OIDCIdentityProvider( - "https:///", - new EnvSecret("OIDC_CLIENT_ID"), - new EnvSecret("OIDC_CLIENT_SECRET"), - new EnvSecret("OIDC_SESSION_SECRET"), - "email" + dal, // DataAccessLayer instance - required first parameter + "https:///.well-known/openid-configuration", + new EnvSecret("OIDC_CLIENT_ID"), + new EnvSecret("OIDC_CLIENT_SECRET"), + new EnvSecret("OIDC_SESSION_SECRET"), + "email", // field to use for identity sub + "oidc", // provider key + true, // capture group claims + "groups" // name of groups claim ); + // Authorization provider - maps groups to roles + const groupToRoleMap = new Map([ + ["gram-admins", Role.Admin], + ["gram-reviewers", Role.Reviewer], + ["gram-users", Role.User], + ]); + + const authzProvider = new OIDCGroupBasedAuthzProvider(dal, { + groupsClaimName: "groups", + groupToRoleMap, + }); + + // User provider + const userProvider = new OIDCUserProvider(dal, { + userInfoToUser: (userInfo) => ({ + sub: userInfo.email, + name: userInfo.name, + mail: userInfo.email, + slackUrl: userInfo.slack_url, + }), + }); + + // Team provider + const teamProvider = new OIDCTeamProvider(dal, { + groupsClaimName: "groups", + // Optional: filter to only include specific groups as teams + groupFilter: ["team-frontend", "team-backend", "team-security"], + groupToTeam: (groupName) => ({ + id: groupName, + name: groupName.replace(/-/g, " "), + email: `${groupName}@company.com`, + }), + }); + + // Reviewer provider + const reviewerProvider = new OIDCGroupBasedReviewerProvider(dal, { + groupsClaimName: "groups", + reviewerGroups: ["gram-reviewers", "gram-security"], + userInfoToReviewer: (userInfo) => ({ + sub: userInfo.email, + name: userInfo.name, + mail: userInfo.email, + recommended: userInfo.groups?.includes("gram-security") || false, + slackUrl: userInfo.slack_url, + }), + fallbackReviewer: { + sub: "security-team@company.com", + name: "Security Team", + mail: "security-team@company.com", + recommended: true, + }, + }); + return { // ... identityProviders: [oidc], + authzProvider: authzProvider, + userProvider: userProvider, + teamProvider: teamProvider, + reviewerProvider: reviewerProvider, } } ``` +### Okta-Specific Configuration + +For Okta, you'll typically want to: + +1. Set up group claims in your Okta application +2. Configure the groups claim name (usually "groups") +3. Map Okta groups to Gram roles + +```ts +// Okta example +const oidc = new OIDCIdentityProvider( + dal, // Required DataAccessLayer parameter + "https://your-org.okta.com/.well-known/openid-configuration", + new EnvSecret("OKTA_CLIENT_ID"), + new EnvSecret("OKTA_CLIENT_SECRET"), + new EnvSecret("OIDC_SESSION_SECRET"), + "email", + "okta", + true, + "groups" // Okta groups claim +); + +// Map your Okta groups to Gram roles +const groupToRoleMap = new Map([ + ["Everyone", Role.User], // Default Okta group + ["Gram Reviewers", Role.Reviewer], + ["Gram Admins", Role.Admin], +]); +``` + +## OIDC Provider Setup + ### At your OIDC provider -Gram uses the `/login/callback/oidc` for the Sign-in redirect URIs. Most likely you will need to allow this at your OIDC provider. +Gram uses the `/login/callback/oidc` (or `/login/callback/{key}` if you use a custom key) for the Sign-in redirect URIs. You'll need to configure this at your OIDC provider. + +Set allowed redirect_url to: +- Production: `https:///login/callback/oidc` +- Development: `http://localhost:4726/login/callback/oidc` + +### Required Claims + +For full functionality, ensure your OIDC provider includes these claims: + +- `sub` or `email` - User identifier +- `name` - User's display name +- `email` - User's email address +- `groups` - User's group memberships (array of strings) + +### Team Group Filtering + +The `OIDCTeamProvider` supports filtering which groups are treated as teams using the `groupFilter` option: + +```ts +const teamProvider = new OIDCTeamProvider(dal, { + groupsClaimName: "groups", + groupFilter: [ + "team-frontend", // Only these groups become teams + "team-backend", + "team-security" + ], + groupToTeam: (groupName) => ({ + id: groupName, + name: groupName.replace(/^team-/, "").replace(/-/g, " "), + email: `${groupName}@company.com`, + }), +}); +``` + +- **With groupFilter**: Only groups in the filter list are converted to teams +- **Without groupFilter**: All user groups are converted to teams +- **Empty groupFilter**: Behaves the same as not providing the option + +This is useful when you have groups for authorization (like "Reviewers", "Admins") that shouldn't be teams, and separate groups for actual teams (like "Frontend Team", "Backend Team"). + +## Environment Variables + +```bash +# Required +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret +OIDC_SESSION_SECRET=base64_encoded_session_secret +``` + +## Database Management + +The OIDC plugin creates two database tables: + +- **oidc_users**: Stores user information (sub, email, name, timestamps) +- **oidc_user_groups**: Stores normalized user-group relationships for efficient querying + +### Available Methods + +The `OIDCUserStore` provides these methods for managing user data: + +```ts +import { OIDCUserStore } from "@gram/oidc"; + +const userStore = OIDCUserStore.getInstance(dal); + +// Store/update user information +await userStore.storeUser({ + sub: "user123", + email: "user@example.com", + name: "John Doe", + groups: ["team-frontend", "reviewers"] +}); + +// Retrieve user information by sub or email +const userInfo = await userStore.getUser("user123"); + +// Get groups for a user +const groups = await userStore.getGroupsForUser("user123"); + +// Get all users in a group +const users = await userStore.getUsersInGroup("reviewers"); + +// Get all unique group names +const allGroups = await userStore.getAllUniqueGroups(); + +// Remove user and all their group memberships +await userStore.removeUser("user123"); + +// Clean up old users (optional maintenance) +await userStore.cleanupOldUsers(90); // older than 90 days +``` + +## Security Considerations + +1. **User Storage**: User information and group memberships are stored in the database for long-term reference and usage +2. **No Token Storage**: Access tokens are not stored in the database for security +3. **Session Security**: Session data is encrypted using AES-256-GCM +4. **Database Claims**: Authorization providers use stored user data from the database, no additional API calls to OIDC provider + +## Troubleshooting + +### Groups Not Available + +If group claims aren't working: + +1. Verify groups are included in the ID token or available via userinfo endpoint +2. Check the groups claim name matches your configuration +3. Ensure your OIDC client has permissions to read group information + +### Database User Issues -Set allowed redirect_url to the equivalent of the domain name you are hosting the application at -`https:///login/callback/oidc` +If authorization seems stale: -For your local development environment you can enable: -`http://localhost:4726/login/callback/oidc` +1. User information is stored in the database - users may need to re-login for immediate updates after group changes +2. Check logs for database query success/failures +3. Authorization providers use stored user data from the database, so group changes require re-authentication to update stored data diff --git a/plugins/oidc/migrations/1_oidc_users_and_groups.sql b/plugins/oidc/migrations/1_oidc_users_and_groups.sql new file mode 100644 index 00000000..073be1a8 --- /dev/null +++ b/plugins/oidc/migrations/1_oidc_users_and_groups.sql @@ -0,0 +1,38 @@ +-- Create users table +CREATE TABLE IF NOT EXISTS oidc_users ( + sub VARCHAR(512) PRIMARY KEY, + email VARCHAR(512), + name VARCHAR(512), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create user groups table +CREATE TABLE IF NOT EXISTS oidc_user_groups ( + id SERIAL PRIMARY KEY, + sub VARCHAR(512) NOT NULL, + group_name VARCHAR(512) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (sub) REFERENCES oidc_users(sub) ON DELETE CASCADE, + UNIQUE(sub, group_name) +); + +-- Indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_oidc_users_email ON oidc_users(email); +CREATE INDEX IF NOT EXISTS idx_oidc_user_groups_sub ON oidc_user_groups(sub); +CREATE INDEX IF NOT EXISTS idx_oidc_user_groups_group_name ON oidc_user_groups(group_name); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_oidc_users_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger to automatically update the updated_at field +CREATE TRIGGER update_oidc_users_updated_at + BEFORE UPDATE ON oidc_users + FOR EACH ROW + EXECUTE FUNCTION update_oidc_users_updated_at(); \ No newline at end of file diff --git a/plugins/oidc/src/OIDCGroupBasedAuthzProvider.ts b/plugins/oidc/src/OIDCGroupBasedAuthzProvider.ts new file mode 100644 index 00000000..61c9f31d --- /dev/null +++ b/plugins/oidc/src/OIDCGroupBasedAuthzProvider.ts @@ -0,0 +1,50 @@ +import { AuthzProvider } from "@gram/core/dist/auth/AuthzProvider.js"; +import { DefaultAuthzProvider } from "@gram/core/dist/auth/DefaultAuthzProvider.js"; +import { Role } from "@gram/core/dist/auth/models/Role.js"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; +import log4js from "log4js"; +import { getUserInfo } from "./util.js"; + +const log = log4js.getLogger("OIDCGroupBasedAuthzProvider"); + +export interface OIDCAuthzProviderSettings { + groupsClaimName: string; + groupToRoleMap: Map; +} + +/** + * An AuthzProvider that uses OIDC group claims from cached authentication + * to determine user roles. + */ +export class OIDCGroupBasedAuthzProvider + extends DefaultAuthzProvider + implements AuthzProvider +{ + constructor( + dal: DataAccessLayer, + public settings: OIDCAuthzProviderSettings + ) { + super(dal); + } + + async getRolesForUser(sub: string): Promise { + const userInfo = await getUserInfo(this.dal, sub, log); + + if (!userInfo || !userInfo.groups) { + return []; + } + + return this.mapGroupsToRoles(userInfo.groups); + } + + private mapGroupsToRoles(groups: string[]): Role[] { + const roles = groups + .map((group) => this.settings.groupToRoleMap.get(group)) + .filter((role): role is Role => role !== undefined); + + log.debug(`Mapped groups to roles:`, { groups, roles }); + return roles; + } + + key = "oidc-group"; +} diff --git a/plugins/oidc/src/OIDCGroupBasedReviewerProvider.ts b/plugins/oidc/src/OIDCGroupBasedReviewerProvider.ts new file mode 100644 index 00000000..477942fa --- /dev/null +++ b/plugins/oidc/src/OIDCGroupBasedReviewerProvider.ts @@ -0,0 +1,164 @@ +import { Reviewer } from "@gram/core/dist/auth/models/Reviewer.js"; +import Model from "@gram/core/dist/data/models/Model.js"; +import { RequestContext } from "@gram/core/dist/data/providers/RequestContext.js"; +import { ReviewerProvider } from "@gram/core/dist/data/reviews/ReviewerProvider.js"; +import log4js from "log4js"; +import { getUserInfo, extractGroups } from "./util.js"; +import { OIDCUserStore } from "./OIDCUserStore.js"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; + +const log = log4js.getLogger("OIDCGroupBasedReviewerProvider"); + +export interface OIDCReviewerProviderSettings { + /** + * Name of the claim that contains group information + */ + groupsClaimName: string; + /** + * Groups that contain reviewers + */ + reviewerGroups: string[]; + /** + * Mapping function to convert user info to Reviewer object + */ + userInfoToReviewer: (userInfo: any) => Reviewer; + /** + * Fallback reviewer when no reviewers are found + */ + fallbackReviewer: string | Reviewer; +} + +export class OIDCGroupBasedReviewerProvider implements ReviewerProvider { + constructor( + private dal: DataAccessLayer, + public settings: OIDCReviewerProviderSettings + ) {} + + async lookup(ctx: RequestContext, userIds: string[]): Promise { + const reviewers = await Promise.all( + userIds.map((uid) => this.getReviewer(uid)) + ); + return reviewers.filter((r) => r).map((r) => r as Reviewer); + } + + async getReviewer(userId: string): Promise { + const userInfo = await getUserInfo(this.dal, userId, log); + + if (!userInfo) { + return null; + } + + // Check if user is actually a reviewer based on their groups + if (!this.isReviewer(userInfo)) { + log.debug(`User ${userId} is not in any reviewer groups`); + return null; + } + + log.debug(`Found stored reviewer info for user ${userId}`); + return this.settings.userInfoToReviewer(userInfo); + } + + async getReviewersForModel( + ctx: RequestContext, + model: Model + ): Promise { + return this.getReviewers(ctx); + } + + async getReviewers(ctx: RequestContext): Promise { + try { + const userStore = OIDCUserStore.getInstance(this.dal); + const reviewers: Reviewer[] = []; + const seenUsers = new Set(); // Track users to avoid duplicates + + log.debug( + `Getting reviewers from groups: ${this.settings.reviewerGroups.join( + ", " + )}` + ); + + // Get all users from reviewer groups + for (const groupName of this.settings.reviewerGroups) { + const usersInGroup = await userStore.getUsersInGroup(groupName); + log.debug( + `Found ${usersInGroup.length} users in reviewer group ${groupName}` + ); + + for (const userSub of usersInGroup) { + // Skip if we've already processed this user + if (seenUsers.has(userSub)) { + continue; + } + seenUsers.add(userSub); + + // Get full user info + const userInfo = await getUserInfo(this.dal, userSub, log); + if (!userInfo) { + log.warn( + `User ${userSub} found in group but no user info available` + ); + continue; + } + + // Verify user is still a reviewer (in case groups changed) + if (!this.isReviewer(userInfo)) { + log.debug(`User ${userSub} no longer in reviewer groups, skipping`); + continue; + } + + // Convert to reviewer object + try { + const reviewer = this.settings.userInfoToReviewer(userInfo); + reviewers.push(reviewer); + log.debug(`Added reviewer: ${reviewer.name} (${reviewer.sub})`); + } catch (error) { + log.error(`Error converting user ${userSub} to reviewer:`, error); + } + } + } + + log.debug(`Found ${reviewers.length} total reviewers`); + + // If no reviewers found, include fallback reviewer + if (reviewers.length === 0) { + log.debug("No reviewers found, adding fallback reviewer"); + const fallbackReviewer = await this.getFallbackReviewer(ctx); + reviewers.push(fallbackReviewer); + } + + return reviewers; + } catch (error) { + log.error("Error getting reviewers:", error); + + // Return fallback reviewer in case of error + try { + const fallbackReviewer = await this.getFallbackReviewer(ctx); + return [fallbackReviewer]; + } catch (fallbackError) { + log.error("Error getting fallback reviewer:", fallbackError); + return []; + } + } + } + + async getFallbackReviewer(ctx: RequestContext): Promise { + if (typeof this.settings.fallbackReviewer === "string") { + const reviewer = await this.getReviewer(this.settings.fallbackReviewer); + if (!reviewer) { + throw new Error( + `Fallback reviewer with userid ${this.settings.fallbackReviewer} does not exist` + ); + } + return reviewer; + } + + return this.settings.fallbackReviewer; + } + + private isReviewer(userInfo: any): boolean { + const groups = extractGroups(userInfo, this.settings.groupsClaimName); + return groups.some((group) => this.settings.reviewerGroups.includes(group)); + } + + key = "oidc-group"; +} diff --git a/plugins/oidc/src/OIDCIdentityProvider.ts b/plugins/oidc/src/OIDCIdentityProvider.ts new file mode 100644 index 00000000..0d2d4713 --- /dev/null +++ b/plugins/oidc/src/OIDCIdentityProvider.ts @@ -0,0 +1,244 @@ +import { + IdentityProvider, + IdentityProviderParams, + LoginResult, +} from "@gram/core/dist/auth/IdentityProvider.js"; +import { Secret } from "@gram/core/dist/config/Secret.js"; +import { config } from "@gram/core/dist/config/index.js"; +import { RequestContext } from "@gram/core/dist/data/providers/RequestContext.js"; +import { InvalidInputError } from "@gram/core/dist/util/errors.js"; +import log4js from "log4js"; +import { Client, Issuer, custom, generators } from "openid-client"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; +import { aes256gcm, extractGroups } from "./util.js"; +import { OIDCUserStore, OIDCUserInfo } from "./OIDCUserStore.js"; + +const log = log4js.getLogger("OIDCIdentityProvider"); + +export class OIDCIdentityProvider implements IdentityProvider { + issuer?: Issuer; + client?: Client; + redirectUrl: string; + sessionCryptoKey: string = ""; + + constructor( + private dal: DataAccessLayer, + private discoverUrl: string | Secret, + private clientId: Secret, + private clientSecret: Secret, + private sessionSecret: Secret, + private fieldForIdentitySub: string = "sub", + public key: string = "oidc", + private captureGroupClaims: boolean = true, + private groupsClaimName: string = "groups" + ) { + this.redirectUrl = `${config.origin}/login/callback/${key}`; + + if (config.httpsProxy) { + custom.setHttpOptionsDefaults({ + timeout: 10000, + }); + } + + this.discover(); + } + + async discover() { + let url = + typeof this.discoverUrl === "string" + ? this.discoverUrl + : await this.discoverUrl.getValue(); + + if (!url) { + throw new Error("discoverUrl cannot be undefined"); + } + + this.issuer = await Issuer.discover(url); + log.info( + "Discovered issuer %s %O", + this.issuer.issuer, + this.issuer.metadata + ); + + const clientId = await this.clientId.getValue(); + + if (!clientId) { + throw new Error("clientId cannot be undefined"); + } + + const clientSecret = await this.clientSecret.getValue(); + + if (!clientSecret) { + throw new Error("clientSecret cannot be undefined"); + } + + this.client = new this.issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [this.redirectUrl], + response_types: ["code"], + }); + + const cryptoKey = await this.sessionSecret.getValue(); + if (!cryptoKey) { + throw new Error("No session secret configured for OIDC Auth Provider"); + } + this.sessionCryptoKey = cryptoKey; + } + + /** + * Non-secret parameters needed by the client. + */ + async params(ctx: RequestContext): Promise { + if (!this.client) { + log.warn("OIDC client not ready yet"); + return { key: this.key }; + } + + const code_verifier = generators.codeVerifier(); + // store the code_verifier in your framework's session mechanism, if it is a cookie based solution + // it should be httpOnly (not readable by javascript) and encrypted. + + const code_challenge = generators.codeChallenge(code_verifier); + const state = generators.state(); + + const frontendRedirectUrl = this.client.authorizationUrl({ + scope: "openid email profile groups", + // resource: origin, + code_challenge, + response_type: "code", + code_challenge_method: "S256", + state, + }); + + ctx.currentRequest?.res?.cookie( + "oidc-code", + aes256gcm(this.sessionCryptoKey) + .encrypt(JSON.stringify({ state, code_verifier })) + .join("."), + { + httpOnly: true, + sameSite: "strict", + } + ); + + return { + key: this.key, + form: { + type: "redirect", + httpMethod: "POST", + redirectUrl: frontendRedirectUrl, + }, + }; + } + + /** + * @param {object} headers + */ + async getIdentity(ctx: RequestContext): Promise { + if (!this.client) { + log.warn("OIDC client not ready yet"); + throw new InvalidInputError("OIDC client not ready yet"); + } + + if (!ctx.currentRequest) { + throw new Error("Request is undefined or null"); + } + + // Check if cookies object exists + if (!ctx.currentRequest.cookies) { + log.error( + "Cookies object is undefined - cookie-parser middleware may not be configured" + ); + + // Try to get cookie from headers directly as fallback + const cookieHeader = ctx.currentRequest.headers?.cookie; + if (cookieHeader) { + const cookies = Object.fromEntries( + cookieHeader + .split(";") + .map((cookie) => cookie.trim().split("=")) + .map(([key, value]) => [key, decodeURIComponent(value)]) + ); + + if (cookies["oidc-code"]) { + ctx.currentRequest.cookies = cookies; + } else { + throw new Error("oidc-code cookie not found in headers"); + } + } else { + throw new Error( + "No cookies found in request. Ensure cookie-parser middleware is configured." + ); + } + } + + const [ct, iv, authTag] = + ctx.currentRequest.cookies["oidc-code"].split("."); + // TODO: make oidc security parameters configurable, since different providers want different things. + const { code_verifier, state } = JSON.parse( + aes256gcm(this.sessionCryptoKey).decrypt(ct, iv, authTag) + ); + + // Clear cookie after use + ctx.currentRequest?.res?.clearCookie("oidc-code"); + + const params = this.client.callbackParams(ctx.currentRequest); + let tokenSet: any; + + try { + tokenSet = await this.client.callback(this.redirectUrl, params, { + code_verifier, + state, + }); + } catch (error: any) { + let message = error.toString(); + if (error?.error === "invalid_grant") { + message = "Login link expired. Try again."; + } + return { + status: "error", + message, + }; + } + + const payload = await this.client.userinfo(tokenSet.access_token as string); + + if (!payload) { + let message = `OIDC error occured: ${decodeURIComponent( + (ctx.currentRequest.query["error_description"] || "")?.toString() + )}`; + + return { + status: "error", + message, + }; + } + + const sub = payload[this.fieldForIdentitySub] as string; + + // Capture and store user info and groups if enabled + if (this.captureGroupClaims) { + // Store simplified user info focusing on authorization needs + const userInfo: OIDCUserInfo = { + sub, // Ensure we use the correct sub field + email: payload.email as string, + name: payload.name as string, + groups: extractGroups(payload, this.groupsClaimName), + }; + + const userStore = OIDCUserStore.getInstance(this.dal); + await userStore.storeUser(userInfo); + log.debug(`Stored user info for user ${sub}`, { + groupCount: userInfo.groups?.length || 0, + }); + } + + return { + status: "ok", + identity: { + sub, + }, + }; + } +} diff --git a/plugins/oidc/src/OIDCTeamProvider.ts b/plugins/oidc/src/OIDCTeamProvider.ts new file mode 100644 index 00000000..d07d08b0 --- /dev/null +++ b/plugins/oidc/src/OIDCTeamProvider.ts @@ -0,0 +1,144 @@ +import { TeamProvider } from "@gram/core/dist/auth/TeamProvider.js"; +import { Team } from "@gram/core/dist/auth/models/Team.js"; +import { RequestContext } from "@gram/core/dist/data/providers/RequestContext.js"; +import { + SearchFilter, + SearchProvider, + SearchProviderResult, + SearchType, +} from "@gram/core/dist/search/SearchHandler.js"; +import log4js from "log4js"; +import { getUserInfo } from "./util.js"; +import { OIDCUserStore } from "./OIDCUserStore.js"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; + +const log = log4js.getLogger("OIDCTeamProvider"); + +export interface OIDCTeamProviderSettings { + /** + * Name of the claim that contains group/team information + */ + groupsClaimName: string; + /** + * Mapping function to convert group/team names to Team objects + */ + groupToTeam: (groupName: string) => Team; + /** + * Optional filter to limit which groups are treated as teams. + * If provided, only groups in this list will be converted to teams. + * If not provided or empty, all groups will be used as teams. + */ + groupFilter?: string[]; +} + +export class OIDCTeamProvider implements TeamProvider, SearchProvider { + constructor( + private dal: DataAccessLayer, + public settings: OIDCTeamProviderSettings + ) {} + + searchType: SearchType = { key: "team", label: "Team" }; + + async search(filter: SearchFilter): Promise { + const result: SearchProviderResult = { + items: [], + count: 0, + }; + + try { + // Get all unique groups from the database + const userStore = OIDCUserStore.getInstance(this.dal); + const allGroups = await userStore.getAllUniqueGroups(); + + // Apply group filter if configured + let filteredGroups = allGroups; + if (this.settings.groupFilter && this.settings.groupFilter.length > 0) { + filteredGroups = allGroups.filter((group) => + this.settings.groupFilter!.includes(group) + ); + } + + // Filter groups based on search text + const searchText = filter.searchText.toLowerCase(); + const matchingGroups = filteredGroups.filter((group) => + group.toLowerCase().includes(searchText) + ); + + // Convert groups to teams and then to search result items + result.items = matchingGroups.map((group) => { + const team = this.settings.groupToTeam(group); + return { + id: team.id, + label: team.name, + url: `/team/${team.id}`, + }; + }); + + result.count = result.items.length; + + // Apply pagination + result.items = result.items.slice( + filter.page * filter.pageSize, + (filter.page + 1) * filter.pageSize + ); + + log.debug(`Team search completed`, { + searchText: filter.searchText, + totalGroups: allGroups.length, + filteredGroups: filteredGroups.length, + matchingTeams: result.count, + returnedItems: result.items.length, + }); + + return result; + } catch (error) { + log.error("Error during team search:", error); + return result; // Return empty result on error + } + } + + async lookup(ctx: RequestContext, teamIds: string[]): Promise { + // Filter team IDs based on groupFilter setting if provided + let filteredTeamIds = teamIds; + if (this.settings.groupFilter && this.settings.groupFilter.length > 0) { + filteredTeamIds = teamIds.filter((teamId) => + this.settings.groupFilter!.includes(teamId) + ); + log.debug(`Filtered team lookup`, { + originalCount: teamIds.length, + filteredCount: filteredTeamIds.length, + filter: this.settings.groupFilter, + }); + } + + // Create team objects from the filtered team IDs using the mapping function + return filteredTeamIds.map((teamId) => this.settings.groupToTeam(teamId)); + } + + async getTeamsForUser(ctx: RequestContext, userId: string): Promise { + const userInfo = await getUserInfo(this.dal, userId, log); + + if (!userInfo || !userInfo.groups) { + return []; + } + + // Filter groups based on groupFilter setting + let filteredGroups = userInfo.groups; + if (this.settings.groupFilter && this.settings.groupFilter.length > 0) { + filteredGroups = userInfo.groups.filter((group: string) => + this.settings.groupFilter!.includes(group) + ); + log.debug(`Filtered groups for user ${userId}`, { + originalCount: userInfo.groups.length, + filteredCount: filteredGroups.length, + filter: this.settings.groupFilter, + }); + } + + return filteredGroups.map((group: string) => + this.settings.groupToTeam(group) + ); + } + + key = "oidc"; +} diff --git a/plugins/oidc/src/OIDCUserProvider.ts b/plugins/oidc/src/OIDCUserProvider.ts new file mode 100644 index 00000000..c73fd908 --- /dev/null +++ b/plugins/oidc/src/OIDCUserProvider.ts @@ -0,0 +1,39 @@ +import { UserProvider } from "@gram/core/dist/auth/UserProvider.js"; +import { User } from "@gram/core/dist/auth/models/User.js"; +import { RequestContext } from "@gram/core/dist/data/providers/RequestContext.js"; +import log4js from "log4js"; +import { getUserInfo } from "./util.js"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; + +const log = log4js.getLogger("OIDCUserProvider"); + +export interface OIDCUserProviderSettings { + /** + * Mapping function to convert OIDC user info to User object + */ + userInfoToUser: (userInfo: any) => User; +} + +export class OIDCUserProvider implements UserProvider { + constructor( + private dal: DataAccessLayer, + public settings: OIDCUserProviderSettings + ) {} + + async lookup(ctx: RequestContext, userIds: string[]): Promise { + const users = await Promise.all(userIds.map((uid) => this.getUser(uid))); + return users.filter((u) => u).map((u) => u as User); + } + + async getUser(userId: string): Promise { + const userInfo = await getUserInfo(this.dal, userId, log); + + if (!userInfo) { + return null; + } + + return this.settings.userInfoToUser(userInfo); + } + + key = "oidc"; +} diff --git a/plugins/oidc/src/OIDCUserStore.ts b/plugins/oidc/src/OIDCUserStore.ts new file mode 100644 index 00000000..662556bf --- /dev/null +++ b/plugins/oidc/src/OIDCUserStore.ts @@ -0,0 +1,260 @@ +import log4js from "log4js"; +import pg from "pg"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; + +const log = log4js.getLogger("OIDCUserStore"); + +export interface OIDCUserInfo { + sub: string; + email?: string; + name?: string; + groups?: string[]; +} + +/** + * Simplified database store for OIDC users and their group memberships. + * Replaces the complex claims storage with a focus on authorization needs. + */ +export class OIDCUserStore { + private static instance: OIDCUserStore; + private dal: DataAccessLayer; + private pool: pg.Pool | null = null; + + private constructor(dal: DataAccessLayer) { + this.dal = dal; + } + + static getInstance(dal: DataAccessLayer): OIDCUserStore { + if (!OIDCUserStore.instance) { + OIDCUserStore.instance = new OIDCUserStore(dal); + } + return OIDCUserStore.instance; + } + + private async getPool(): Promise { + if (!this.pool) { + this.pool = await this.dal.pluginPool("oidc"); + } + return this.pool; + } + + /** + * Store user information and group memberships + */ + async storeUser(userInfo: OIDCUserInfo): Promise { + const pool = await this.getPool(); + const client = await pool.connect(); + try { + log.debug(`Storing user ${userInfo.sub}`); + await client.query("BEGIN"); + + // Upsert user + await client.query( + `INSERT INTO oidc_users (sub, email, name) + VALUES ($1, $2, $3) + ON CONFLICT (sub) + DO UPDATE SET + email = EXCLUDED.email, + name = EXCLUDED.name, + updated_at = CURRENT_TIMESTAMP`, + [userInfo.sub, userInfo.email || null, userInfo.name || null] + ); + + // Clear existing groups for this user + await client.query("DELETE FROM oidc_user_groups WHERE sub = $1", [ + userInfo.sub, + ]); + + // Insert new groups if they exist + if (userInfo.groups && userInfo.groups.length > 0) { + // Deduplicate group names + const uniqueGroups = Array.from(new Set(userInfo.groups)); + const groupInsertPromises = uniqueGroups.map((group) => + client.query( + "INSERT INTO oidc_user_groups (sub, group_name) VALUES ($1, $2)", + [userInfo.sub, group] + ) + ); + await Promise.all(groupInsertPromises); + } + + await client.query("COMMIT"); + log.debug( + `Successfully stored user ${userInfo.sub} with ${ + userInfo.groups?.length || 0 + } groups` + ); + } catch (error) { + await client.query("ROLLBACK"); + log.error(`Error storing user ${userInfo.sub}:`, error); + throw error; + } finally { + client.release(); + } + } + + /** + * Retrieve user information including groups by sub or email + */ + async getUser(identifier: string): Promise { + try { + const pool = await this.getPool(); + + // Try to get user info by sub first + let userResult = await pool.query( + "SELECT * FROM oidc_users WHERE sub = $1", + [identifier] + ); + + // If not found by sub, try by email + if (userResult.rowCount === 0) { + userResult = await pool.query( + "SELECT * FROM oidc_users WHERE email = $1", + [identifier] + ); + + if (userResult.rowCount === 0) { + log.debug(`No user found with identifier ${identifier}`); + return null; + } + + log.debug(`User found by email for identifier ${identifier}`); + } else { + log.debug(`User found by sub for identifier ${identifier}`); + } + + const userRow = userResult.rows[0]; + + // Get user groups + const groupsResult = await pool.query( + "SELECT group_name FROM oidc_user_groups WHERE sub = $1 ORDER BY group_name", + [userRow.sub] + ); + + const groups = groupsResult.rows.map((row) => row.group_name); + + const userInfo: OIDCUserInfo = { + sub: userRow.sub, + email: userRow.email, + name: userRow.name, + groups, + }; + + log.debug( + `Retrieved user ${identifier} (sub: ${userRow.sub}) with ${groups.length} groups` + ); + return userInfo; + } catch (error) { + log.error(`Error retrieving user ${identifier}:`, error); + return null; + } + } + + /** + * Remove user and all their group memberships + */ + async removeUser(sub: string): Promise { + try { + log.debug(`Removing user ${sub}`); + const pool = await this.getPool(); + + // Delete user (groups will be deleted automatically due to foreign key cascade) + const result = await pool.query("DELETE FROM oidc_users WHERE sub = $1", [ + sub, + ]); + + if (result.rowCount && result.rowCount > 0) { + log.debug(`Successfully removed user ${sub}`); + } else { + log.debug(`No user found to remove with sub ${sub}`); + } + } catch (error) { + log.error(`Error removing user ${sub}:`, error); + throw error; + } + } + + /** + * Get groups for a user + */ + async getGroupsForUser(sub: string): Promise { + try { + const pool = await this.getPool(); + + const result = await pool.query( + "SELECT group_name FROM oidc_user_groups WHERE sub = $1 ORDER BY group_name", + [sub] + ); + + const groups = result.rows.map((row) => row.group_name); + log.debug(`Retrieved ${groups.length} groups for user ${sub}`); + return groups; + } catch (error) { + log.error(`Error retrieving groups for user ${sub}:`, error); + return []; + } + } + + /** + * Get all users in a specific group + */ + async getUsersInGroup(groupName: string): Promise { + try { + const pool = await this.getPool(); + + const result = await pool.query( + "SELECT sub FROM oidc_user_groups WHERE group_name = $1 ORDER BY sub", + [groupName] + ); + + const users = result.rows.map((row) => row.sub); + log.debug(`Retrieved ${users.length} users in group ${groupName}`); + return users; + } catch (error) { + log.error(`Error retrieving users in group ${groupName}:`, error); + return []; + } + } + + /** + * Get all unique group names + */ + async getAllUniqueGroups(): Promise { + try { + const pool = await this.getPool(); + + const result = await pool.query( + "SELECT DISTINCT group_name FROM oidc_user_groups ORDER BY group_name" + ); + + const groups = result.rows.map((row) => row.group_name); + log.debug(`Retrieved ${groups.length} unique groups`); + return groups; + } catch (error) { + log.error(`Error retrieving unique groups:`, error); + return []; + } + } + + /** + * Clean up old users (optional method for maintenance) + */ + async cleanupOldUsers(olderThanDays: number = 90): Promise { + try { + log.debug(`Cleaning up users older than ${olderThanDays} days`); + const pool = await this.getPool(); + + const result = await pool.query( + "DELETE FROM oidc_users WHERE updated_at < NOW() - ($1 || ' days')::INTERVAL", + [olderThanDays] + ); + + const deletedCount = result.rowCount || 0; + log.info(`Cleaned up ${deletedCount} old OIDC users`); + return deletedCount; + } catch (error) { + log.error(`Error cleaning up old users:`, error); + return 0; + } + } +} diff --git a/plugins/oidc/src/index.ts b/plugins/oidc/src/index.ts index c3befa3f..886ad533 100644 --- a/plugins/oidc/src/index.ts +++ b/plugins/oidc/src/index.ts @@ -1,220 +1,27 @@ -import { - IdentityProvider, - IdentityProviderParams, - LoginResult, -} from "@gram/core/dist/auth/IdentityProvider.js"; -import { Secret } from "@gram/core/dist/config/Secret.js"; -import { config } from "@gram/core/dist/config/index.js"; -import { RequestContext } from "@gram/core/dist/data/providers/RequestContext.js"; -import { InvalidInputError } from "@gram/core/dist/util/errors.js"; -import log4js from "log4js"; -import { Client, Issuer, custom, generators } from "openid-client"; -import { aes256gcm } from "./util.js"; - -const log = log4js.getLogger("OIDCIdentityProvider"); - -export class OIDCIdentityProvider implements IdentityProvider { - issuer?: Issuer; - client?: Client; - redirectUrl: string; - sessionCryptoKey: string = ""; - - constructor( - private discoverUrl: string | Secret, - private clientId: Secret, - private clientSecret: Secret, - private sessionSecret: Secret, - private fieldForIdentitySub: string = "sub", - public key: string = "oidc" - ) { - this.redirectUrl = `${config.origin}/login/callback/${key}`; - - if (config.httpsProxy) { - custom.setHttpOptionsDefaults({ - timeout: 10000, - }); - } - - this.discover(); - } - - async discover() { - let url = - typeof this.discoverUrl === "string" - ? this.discoverUrl - : await this.discoverUrl.getValue(); - - if (!url) { - throw new Error("discoverUrl cannot be undefined"); - } - - this.issuer = await Issuer.discover(url); - log.info( - "Discovered issuer %s %O", - this.issuer.issuer, - this.issuer.metadata - ); - - const clientId = await this.clientId.getValue(); - - if (!clientId) { - throw new Error("clientId cannot be undefined"); - } - - const clientSecret = await this.clientSecret.getValue(); - - if (!clientSecret) { - throw new Error("clientSecret cannot be undefined"); - } - - this.client = new this.issuer.Client({ - client_id: clientId, - client_secret: clientSecret, - redirect_uris: [this.redirectUrl], - response_types: ["code"], - }); - - const cryptoKey = await this.sessionSecret.getValue(); - if (!cryptoKey) { - throw new Error("No session secret configured for OIDC Auth Provider"); - } - this.sessionCryptoKey = cryptoKey; - } - - /** - * Non-secret parameters needed by the client. - */ - async params(ctx: RequestContext): Promise { - if (!this.client) { - log.warn("OIDC client not ready yet"); - return { key: this.key }; - } - - const code_verifier = generators.codeVerifier(); - // store the code_verifier in your framework's session mechanism, if it is a cookie based solution - // it should be httpOnly (not readable by javascript) and encrypted. - - const code_challenge = generators.codeChallenge(code_verifier); - const state = generators.state(); - - const frontendRedirectUrl = this.client.authorizationUrl({ - scope: "openid email profile groups", - // resource: origin, - code_challenge, - response_type: "code", - code_challenge_method: "S256", - state, - }); - - ctx.currentRequest?.res?.cookie( - "oidc-code", - aes256gcm(this.sessionCryptoKey) - .encrypt(JSON.stringify({ state, code_verifier })) - .join("."), - { - httpOnly: true, - sameSite: "strict", - } - ); - - return { - key: this.key, - form: { - type: "redirect", - httpMethod: "POST", - redirectUrl: frontendRedirectUrl, - }, - }; - } - - /** - * @param {object} headers - */ - async getIdentity(ctx: RequestContext): Promise { - if (!this.client) { - log.warn("OIDC client not ready yet"); - throw new InvalidInputError("OIDC client not ready yet"); - } - - if (!ctx.currentRequest) { - throw new Error("Request is undefined or null"); - } - - // Check if cookies object exists - if (!ctx.currentRequest.cookies) { - log.error( - "Cookies object is undefined - cookie-parser middleware may not be configured" - ); - - // Try to get cookie from headers directly as fallback - const cookieHeader = ctx.currentRequest.headers?.cookie; - if (cookieHeader) { - const cookies = Object.fromEntries( - cookieHeader - .split(";") - .map((cookie) => cookie.trim().split("=")) - .map(([key, value]) => [key, decodeURIComponent(value)]) - ); - - if (cookies["oidc-code"]) { - ctx.currentRequest.cookies = cookies; - } else { - throw new Error("oidc-code cookie not found in headers"); - } - } else { - throw new Error( - "No cookies found in request. Ensure cookie-parser middleware is configured." - ); - } - } - - const [ct, iv, authTag] = - ctx.currentRequest.cookies["oidc-code"].split("."); - // TODO: make oidc security parameters configurable, since different providers want different things. - const { code_verifier, state } = JSON.parse( - aes256gcm(this.sessionCryptoKey).decrypt(ct, iv, authTag) - ); - - // Clear cookie after use - ctx.currentRequest?.res?.clearCookie("oidc-code"); - - const params = this.client.callbackParams(ctx.currentRequest); - let tokenSet: any; - - try { - tokenSet = await this.client.callback(this.redirectUrl, params, { - code_verifier, - state, - }); - } catch (error: any) { - let message = error.toString(); - if (error?.error === "invalid_grant") { - message = "Login link expired. Try again."; - } - return { - status: "error", - message, - }; - } - - const payload = await this.client.userinfo(tokenSet.access_token as string); - - if (!payload) { - let message = `OIDC error occured: ${decodeURIComponent( - (ctx.currentRequest.query["error_description"] || "")?.toString() - )}`; - - return { - status: "error", - message, - }; - } - - return { - status: "ok", - identity: { - sub: payload[this.fieldForIdentitySub] as string, - }, - }; - } -} +import { Migration } from "@gram/core/dist/data/Migration.js"; +import path from "path"; +import * as url from "url"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + +// Create OIDC migrations object +const OIDCMigrations = new Migration( + path.join(__dirname, "..", "migrations"), + "oidc" +); + +// Export all OIDC providers and their types +export { OIDCIdentityProvider } from "./OIDCIdentityProvider.js"; +export { OIDCGroupBasedAuthzProvider } from "./OIDCGroupBasedAuthzProvider.js"; +export { OIDCUserProvider } from "./OIDCUserProvider.js"; +export { OIDCTeamProvider } from "./OIDCTeamProvider.js"; +export { OIDCGroupBasedReviewerProvider } from "./OIDCGroupBasedReviewerProvider.js"; +export { OIDCUserStore } from "./OIDCUserStore.js"; +export { OIDCMigrations }; + +// Export types +export type { OIDCUserInfo } from "./OIDCUserStore.js"; +export type { OIDCAuthzProviderSettings } from "./OIDCGroupBasedAuthzProvider.js"; +export type { OIDCUserProviderSettings } from "./OIDCUserProvider.js"; +export type { OIDCTeamProviderSettings } from "./OIDCTeamProvider.js"; +export type { OIDCReviewerProviderSettings } from "./OIDCGroupBasedReviewerProvider.js"; diff --git a/plugins/oidc/src/util.ts b/plugins/oidc/src/util.ts index 6cf045fc..6d0202df 100644 --- a/plugins/oidc/src/util.ts +++ b/plugins/oidc/src/util.ts @@ -1,5 +1,7 @@ // Taken from https://gist.github.com/rjz/15baffeab434b8125ca4d783f4116d81 import crypto from "crypto"; +import { DataAccessLayer } from "@gram/core/dist/data/dal.js"; +import { OIDCUserStore, OIDCUserInfo } from "./OIDCUserStore.js"; export const aes256gcm = (key: string) => { const ALGO = "aes-256-gcm"; @@ -26,7 +28,8 @@ export const aes256gcm = (key: string) => { const decipher = crypto.createDecipheriv( ALGO, keyBytes, - Buffer.from(iv, "base64") + Buffer.from(iv, "base64"), + { authTagLength: 16 } ); decipher.setAuthTag(Buffer.from(authTag, "base64")); let str = decipher.update(enc, "base64", "utf8"); @@ -39,3 +42,66 @@ export const aes256gcm = (key: string) => { decrypt, }; }; + +// Shared utility functions for OIDC providers + +/** + * Extract groups from OIDC claims/user info data + * @param data The data object containing group information + * @param claimName The name of the claim/field containing groups + * @returns Array of group names + */ +export function extractGroups(data: any, claimName: string): string[] { + const groups = data[claimName]; + if (!groups) { + return []; + } + + if (Array.isArray(groups)) { + return groups.map((g) => g.toString()); + } + + if (typeof groups === "string") { + return [groups]; + } + + return []; +} + +/** + * Retrieve user info from the OIDC user store with standard validation and logging + * @param dal Data access layer instance + * @param userId User identifier (sub or email) + * @param log Logger instance + * @returns User info or null if not found/invalid + */ +export async function getUserInfo( + dal: DataAccessLayer, + userId: string, + log: any +): Promise { + try { + const userStore = OIDCUserStore.getInstance(dal); + const userInfo = await userStore.getUser(userId); + + if (!userInfo) { + log.debug(`No stored user info found for user ${userId}`); + return null; + } + + // Validate that stored user info matches requested user + if (userInfo.sub !== userId && userInfo.email !== userId) { + log.debug(`Stored user info doesn't match requested userId ${userId}`); + return null; + } + + log.debug(`Found stored user info for user ${userId}`, { + groupCount: userInfo.groups?.length || 0, + }); + + return userInfo; + } catch (error) { + log.error(`Error fetching user info for ${userId}:`, error); + return null; + } +}