first commit
This commit is contained in:
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal file
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal 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;
|
||||
}
|
||||
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal file
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal 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,
|
||||
);
|
||||
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal file
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user