Files
orders/src/auth.ts
T
Ruslan Bakiev 615f6615e0
Build and deploy Docker image / build (push) Successful in 2m50s
Authorize default client team context
2026-08-20 18:51:14 +07:00

170 lines
4.6 KiB
TypeScript

import {
createRemoteJWKSet,
errors,
jwtVerify,
type JWTPayload,
type JWTVerifyOptions,
} from "jose";
import mercurius, {
type ErrorWithProps as MercuriusErrorWithProps,
} from "mercurius";
import type { FastifyRequest } from "fastify";
const LOGTO_JWKS_URL =
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
const LOGTO_ORDERS_AUDIENCE =
process.env.LOGTO_ORDERS_AUDIENCE || "https://orders.optovia.ru";
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
const { ErrorWithProps } = mercurius;
export interface AuthContext {
userId?: string;
teamUuid?: string;
scopes: string[];
}
function getBearerToken(req: FastifyRequest): string {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) {
throw unauthenticated("Missing Bearer token");
}
const token = auth.slice(7);
if (!token || token === "undefined") {
throw unauthenticated("Empty Bearer token");
}
return token;
}
function unauthenticated(message = "Unauthorized"): MercuriusErrorWithProps {
return new ErrorWithProps(message, { code: "UNAUTHENTICATED" }, 401);
}
function forbidden(message: string): MercuriusErrorWithProps {
return new ErrorWithProps(message, { code: "FORBIDDEN" }, 403);
}
async function verifyLogtoJwt(
token: string,
options: Omit<JWTVerifyOptions, "issuer"> = {},
): Promise<JWTPayload> {
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
...options,
});
return payload;
} catch (error) {
if (error instanceof errors.JOSEError) {
throw unauthenticated();
}
throw error;
}
}
function optionalBearerToken(req: FastifyRequest): string | null {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Bearer ")) return null;
const token = auth.slice(7);
if (!token || token === "undefined") return null;
return token;
}
function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope;
if (!scope) return [];
if (typeof scope === "string") return scope.split(" ");
if (Array.isArray(scope)) return scope as string[];
return [];
}
function clientScopes(payload: JWTPayload): string[] {
return [
...new Set([...scopesFromPayload(payload), "teams:user", "teams:member"]),
];
}
function claimList(payload: JWTPayload, key: string): string[] {
const value = (payload as Record<string, unknown>)[key];
if (typeof value === "string") return value.split(" ");
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string");
return [];
}
function hasManagerClaim(payload: JWTPayload): boolean {
const scopes = scopesFromPayload(payload);
return (
scopes.includes("manager") ||
claimList(payload, "roles").includes("manager") ||
claimList(payload, "permissions").includes("manager")
);
}
export async function publicContext(): Promise<AuthContext> {
return { scopes: [] };
}
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
const payload = await verifyLogtoJwt(token);
return {
userId: payload.sub,
scopes: scopesFromPayload(payload),
};
}
export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
const payload = await verifyLogtoJwt(token, {
audience: LOGTO_ORDERS_AUDIENCE,
});
const claimedTeamUuid = (payload as Record<string, unknown>).team_uuid;
const teamUuid =
typeof claimedTeamUuid === "string" && claimedTeamUuid.length > 0
? claimedTeamUuid
: payload.sub;
const scopes = clientScopes(payload);
if (!payload.sub || !teamUuid) {
throw unauthenticated();
}
return {
userId: payload.sub,
teamUuid,
scopes,
};
}
export async function managerContext(
req: FastifyRequest,
): Promise<AuthContext> {
const token = optionalBearerToken(req);
if (token === null) return { scopes: [] };
const payload = await verifyLogtoJwt(token, {
audience: LOGTO_ORDERS_AUDIENCE,
});
const scopes = scopesFromPayload(payload);
const teamUuid = (payload as Record<string, unknown>).team_uuid as
| string
| undefined;
if (!payload.sub || !hasManagerClaim(payload)) {
throw unauthenticated();
}
return {
userId: payload.sub,
teamUuid,
scopes: [...new Set([...scopes, "manager"])],
};
}
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter((s) => !ctx.scopes.includes(s));
if (missing.length > 0) {
throw forbidden(`Missing required scopes: ${missing.join(", ")}`);
}
}