diff --git a/packages/services/api/src/context.ts b/packages/services/api/src/context.ts index 316d326dc2b..a1bf6176949 100644 --- a/packages/services/api/src/context.ts +++ b/packages/services/api/src/context.ts @@ -1,4 +1,5 @@ import type { FastifyRequest } from '@hive/service-common'; +import { Session } from './modules/auth/lib/authz'; export interface RegistryContext { req: FastifyRequest; @@ -6,6 +7,7 @@ export interface RegistryContext { user: any; headers: Record; request: Request; + session: Session; } declare global { diff --git a/packages/services/api/src/modules/auth/index.ts b/packages/services/api/src/modules/auth/index.ts index 03dc41bc722..c640d657a22 100644 --- a/packages/services/api/src/modules/auth/index.ts +++ b/packages/services/api/src/modules/auth/index.ts @@ -3,7 +3,6 @@ import { AuthManager } from './providers/auth-manager'; import { OrganizationAccess } from './providers/organization-access'; import { ProjectAccess } from './providers/project-access'; import { TargetAccess } from './providers/target-access'; -import { ApiTokenProvider } from './providers/tokens'; import { UserManager } from './providers/user-manager'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -13,12 +12,5 @@ export const authModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [ - AuthManager, - UserManager, - ApiTokenProvider, - OrganizationAccess, - ProjectAccess, - TargetAccess, - ], + providers: [AuthManager, UserManager, OrganizationAccess, ProjectAccess, TargetAccess], }); diff --git a/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts new file mode 100644 index 00000000000..3d1a88e7840 --- /dev/null +++ b/packages/services/api/src/modules/auth/lib/target-access-token-strategy.ts @@ -0,0 +1,131 @@ +import type { FastifyReply, FastifyRequest, ServiceLogger } from '@hive/service-common'; +import { TokenStorage } from '../../token/providers/token-storage'; +import { TokensConfig } from '../../token/providers/tokens'; +import { + OrganizationAccessScope, + ProjectAccessScope, + TargetAccessScope, +} from '../providers/scopes'; +import { AuthNStrategy, AuthorizationPolicyStatement, Session } from './authz'; +import { transformLegacyPolicies } from './legacy-permissions'; + +export class TargetAccessTokenSession extends Session { + public readonly organizationId: string; + public readonly projectId: string; + public readonly targetId: string; + public readonly token: string; + + private policies: Array; + + constructor(args: { + organizationId: string; + projectId: string; + targetId: string; + token: string; + policies: Array; + }) { + super(); + this.organizationId = args.organizationId; + this.projectId = args.projectId; + this.targetId = args.targetId; + this.token = args.token; + this.policies = args.policies; + } + + protected loadPolicyStatementsForOrganization( + _: string, + ): Promise> | Array { + return this.policies; + } +} + +export class TargetAccessTokenStrategy extends AuthNStrategy { + private logger: ServiceLogger; + private tokensConfig: TokensConfig; + + constructor(deps: { logger: ServiceLogger; tokensConfig: TokensConfig }) { + super(); + this.logger = deps.logger.child({ module: 'OrganizationAccessTokenStrategy' }); + this.tokensConfig = deps.tokensConfig; + } + + async parse(args: { + req: FastifyRequest; + reply: FastifyReply; + }): Promise { + this.logger.debug('Attempt to resolve an API token from headers'); + let accessToken: string | undefined; + + for (const headerName in args.req.headers) { + if (headerName.toLowerCase() === 'x-api-token') { + const values = args.req.headers[headerName]; + const singleValue = Array.isArray(values) ? values[0] : values; + + if (singleValue && singleValue !== '') { + this.logger.debug( + 'Found X-API-Token header (length=%d, token=%s)', + singleValue.length, + maskToken(singleValue), + ); + accessToken = singleValue; + break; + } + } else if (headerName.toLowerCase() === 'authorization') { + const values = args.req.headers[headerName]; + const singleValue = Array.isArray(values) ? values[0] : values; + + if (singleValue && singleValue !== '') { + const bearer = singleValue.replace(/^Bearer\s+/i, ''); + + // Skip if bearer is missing or it's JWT generated by Auth0 (not API token) + if (bearer && bearer !== '' && !bearer.includes('.')) { + this.logger.debug( + 'Found Authorization header (length=%d, token=%s)', + bearer.length, + maskToken(bearer), + ); + accessToken = bearer; + break; + } + } + } + } + + if (!accessToken) { + this.logger.debug('No access token found'); + return null; + } + + if (accessToken.length !== 32) { + this.logger.debug('Invalid access token length.'); + return null; + } + + const tokens = new TokenStorage(this.logger, this.tokensConfig, { + requestId: args.req.headers['x-request-id'] as string, + } as any); + + const result = await tokens.getToken({ token: accessToken }); + + return new TargetAccessTokenSession({ + organizationId: result.organization, + projectId: result.project, + targetId: result.target, + token: accessToken, + policies: transformLegacyPolicies( + result.organization, + result.project, + result.target, + result.scopes as Array, + ), + }); + } +} + +function maskToken(token: string) { + if (token.length > 6) { + return token.substring(0, 3) + '*'.repeat(token.length - 6) + token.substring(token.length - 3); + } + + return '*'.repeat(token.length); +} diff --git a/packages/services/api/src/modules/auth/providers/auth-manager.ts b/packages/services/api/src/modules/auth/providers/auth-manager.ts index fe15b5b6f9d..b43917fdc96 100644 --- a/packages/services/api/src/modules/auth/providers/auth-manager.ts +++ b/packages/services/api/src/modules/auth/providers/auth-manager.ts @@ -1,4 +1,5 @@ import { CONTEXT, Inject, Injectable, Scope } from 'graphql-modules'; +import type { RegistryContext } from '../../../context'; import type { User } from '../../../shared/entities'; import { AccessError } from '../../../shared/errors'; import type { Listify, MapToArray } from '../../../shared/helpers'; @@ -7,6 +8,7 @@ import { Storage } from '../../shared/providers/storage'; import { TokenStorage } from '../../token/providers/token-storage'; import { Session } from '../lib/authz'; import { SuperTokensCookieBasedSession } from '../lib/supertokens-strategy'; +import { TargetAccessTokenSession } from '../lib/target-access-token-strategy'; import { OrganizationAccess, OrganizationAccessScope, @@ -14,7 +16,6 @@ import { } from './organization-access'; import { ProjectAccess, ProjectAccessScope, ProjectUserScopesSelector } from './project-access'; import { TargetAccess, TargetAccessScope, TargetUserScopesSelector } from './target-access'; -import { ApiToken } from './tokens'; import { UserManager } from './user-manager'; export interface OrganizationAccessSelector { @@ -47,8 +48,7 @@ export class AuthManager { private session: Session; constructor( - @Inject(ApiToken) private apiToken: string, - @Inject(CONTEXT) context: any, + @Inject(CONTEXT) context: RegistryContext, private organizationAccess: OrganizationAccess, private projectAccess: ProjectAccess, private targetAccess: TargetAccess, @@ -62,7 +62,7 @@ export class AuthManager { async ensureTargetAccess( selector: Listify, ): Promise { - if (this.apiToken) { + if (this.session instanceof TargetAccessTokenSession) { if (hasManyTargets(selector)) { await Promise.all( selector.target.map(target => @@ -75,7 +75,7 @@ export class AuthManager { } else { await this.targetAccess.ensureAccessForToken({ ...(selector as TargetAccessSelector), - token: this.apiToken, + token: this.session.token, }); } } else if (hasManyTargets(selector)) { @@ -97,10 +97,10 @@ export class AuthManager { } async ensureProjectAccess(selector: ProjectAccessSelector): Promise { - if (this.apiToken) { + if (this.session instanceof TargetAccessTokenSession) { await this.projectAccess.ensureAccessForToken({ ...selector, - token: this.apiToken, + token: this.session.token, }); } else { const user = await this.getCurrentUser(); @@ -112,10 +112,10 @@ export class AuthManager { } async ensureOrganizationAccess(selector: OrganizationAccessSelector): Promise { - if (this.apiToken) { + if (this.session instanceof TargetAccessTokenSession) { await this.organizationAccess.ensureAccessForToken({ ...selector, - token: this.apiToken, + token: this.session.token, }); } else { const user = await this.getCurrentUser(); @@ -133,7 +133,7 @@ export class AuthManager { } async checkOrganizationAccess(selector: OrganizationAccessSelector): Promise { - if (this.apiToken) { + if (this.session instanceof TargetAccessTokenSession) { throw new Error('checkOrganizationAccess for token is not implemented yet'); } @@ -158,11 +158,11 @@ export class AuthManager { } ensureApiToken(): string | never { - if (this.apiToken) { - return this.apiToken; + if (!(this.session instanceof TargetAccessTokenSession)) { + throw new AccessError('Authorization header is missing'); } - throw new AccessError('Authorization header is missing'); + return this.session.token; } getOrganizationOwnerByToken: () => Promise = share(async () => { diff --git a/packages/services/api/src/modules/auth/providers/tokens.ts b/packages/services/api/src/modules/auth/providers/tokens.ts deleted file mode 100644 index 1502da99b3c..00000000000 --- a/packages/services/api/src/modules/auth/providers/tokens.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { CONTEXT, FactoryProvider, InjectionToken, Scope } from 'graphql-modules'; -import type { RegistryContext } from './../../../context'; - -function maskToken(token: string) { - if (token.length > 6) { - return token.substring(0, 3) + '*'.repeat(token.length - 6) + token.substring(token.length - 3); - } - - return '*'.repeat(token.length); -} - -export const ApiToken = new InjectionToken('x-api-token'); -export const ApiTokenProvider: FactoryProvider = { - provide: ApiToken, - useFactory(context: RegistryContext) { - context.req.log.debug('Attempt to resolve an API token from headers'); - let token: string | undefined; - - for (const headerName in context.headers) { - if (headerName.toLowerCase() === 'x-api-token') { - const values = context.headers[headerName]; - const singleValue = Array.isArray(values) ? values[0] : values; - - if (singleValue && singleValue !== '') { - context.req.log.debug( - 'Found X-API-Token header (length=%d, token=%s)', - singleValue.length, - maskToken(singleValue), - ); - token = singleValue; - break; - } - } else if (headerName.toLowerCase() === 'authorization') { - const values = context.headers[headerName]; - const singleValue = Array.isArray(values) ? values[0] : values; - - if (singleValue && singleValue !== '') { - const bearer = singleValue.replace(/^Bearer\s+/i, ''); - - // Skip if bearer is missing or it's JWT generated by Auth0 (not API token) - if (bearer && bearer !== '' && !bearer.includes('.')) { - context.req.log.debug( - 'Found Authorization header (length=%d, token=%s)', - bearer.length, - maskToken(bearer), - ); - token = bearer; - break; - } - } - } - } - - return token; - }, - deps: [CONTEXT], - scope: Scope.Operation, -}; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 25247602382..b0cef48ad75 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -12,6 +12,7 @@ import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; import 'reflect-metadata'; import { hostname } from 'os'; import { createPubSub } from 'graphql-yoga'; +import { TargetAccessTokenStrategy } from 'packages/services/api/src/modules/auth/lib/target-access-token-strategy'; import { z } from 'zod'; import formDataPlugin from '@fastify/formbody'; import { createRegistry, createTaskRunner, CryptoProvider, LogFn, Logger } from '@hive/api'; @@ -396,6 +397,12 @@ export async function main() { logger: server.log, storage, }), + new TargetAccessTokenStrategy({ + logger: server.log, + tokensConfig: { + endpoint: env.hiveServices.tokens.endpoint, + }, + }), ], });