first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
import { ExecutionContext } from '@nestjs/common';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
/** Request shape shared by HTTP, GraphQL and GraphQL-over-WebSocket. */
export interface AuthRequest {
headers: Record<string, string | string[] | undefined>;
user?: User;
accessToken?: string;
}
export function getAuthRequest(context: ExecutionContext): AuthRequest {
if (context.getType<GqlContextType>() === 'graphql') {
return GqlExecutionContext.create(context).getContext<{
req: AuthRequest;
}>().req;
}
return context.switchToHttp().getRequest<AuthRequest>();
}
export function extractBearerToken(value: unknown): string | null {
if (typeof value !== 'string') return null;
const [scheme, token] = value.trim().split(/\s+/);
return scheme?.toLowerCase() === 'bearer' && token ? token : null;
}

View File

@@ -0,0 +1,27 @@
import {
ExecutionContext,
SetMetadata,
createParamDecorator,
} from '@nestjs/common';
import { getAuthRequest } from './auth-request';
export const IS_PUBLIC = 'identity:isPublic';
export const REQUIRED_APP_ROLES = 'identity:appRoles';
/** Skips authentication (health checks, webhooks...). */
export const Public = () => SetMetadata(IS_PUBLIC, true);
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
export const RequireAppRoles = (...roles: string[]) =>
SetMetadata(REQUIRED_APP_ROLES, roles);
/** The authenticated caller (User). */
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
);
/** The caller's raw access token, to call Pireon on their behalf. */
export const AccessToken = createParamDecorator(
(_: unknown, context: ExecutionContext) =>
getAuthRequest(context).accessToken,
);

View File

@@ -0,0 +1,56 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
IdentityService,
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { extractBearerToken, getAuthRequest } from './auth-request';
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly identity: IdentityService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler(), context.getClass()];
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
return true;
const req = getAuthRequest(context);
// WebSocket subscriptions are authenticated once in connection_init.
if (!req.user) {
const token = extractBearerToken(req.headers['authorization']);
if (!token) throw new UnauthorizedException('Missing bearer token');
try {
req.user = await this.identity.resolveSession(token);
req.accessToken = token;
} catch (error) {
if (error instanceof InvalidAccessTokenError)
throw new UnauthorizedException(error.message);
throw error;
}
}
const roles = this.reflector.getAllAndOverride<string[]>(
REQUIRED_APP_ROLES,
targets,
);
const user = req.user;
if (roles?.length && user && !roles.some((role) => user.hasAppRole(role))) {
throw new ForbiddenException(
`Requires one of the app roles: ${roles.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { IdentityService } from '@task-stream/api/identity/application';
import { AuthRequest, extractBearerToken } from './auth-request';
/**
* Authenticates graphql-ws connections from `connection_init` params:
* `{ "authorization": "Bearer <token>" }`.
*/
@Injectable()
export class WsAuthenticator {
constructor(private readonly identity: IdentityService) {}
async authenticate(
connectionParams: Record<string, unknown> | undefined,
): Promise<AuthRequest> {
const header =
connectionParams?.['authorization'] ??
connectionParams?.['Authorization'];
const token = extractBearerToken(header);
if (!token) throw new Error('Missing bearer token in connection params');
const user = await this.identity.resolveSession(token);
return {
headers: { authorization: `Bearer ${token}` },
user,
accessToken: token,
};
}
}