jasonraimondi/typescript-oauth2-server

View on GitHub
src/grants/abstract/abstract.grant.ts

Summary

Maintainability
A
0 mins
Test Coverage
B
88%
import { AuthorizationServerOptions } from "../../authorization_server.js";
import { isClientConfidential, OAuthClient } from "../../entities/client.entity.js";
import { OAuthScope } from "../../entities/scope.entity.js";
import { OAuthToken } from "../../entities/token.entity.js";
import { OAuthUser } from "../../entities/user.entity.js";
import { OAuthException } from "../../exceptions/oauth.exception.js";
import { OAuthTokenRepository } from "../../repositories/access_token.repository.js";
import { OAuthAuthCodeRepository } from "../../repositories/auth_code.repository.js";
import { OAuthClientRepository } from "../../repositories/client.repository.js";
import { OAuthScopeRepository } from "../../repositories/scope.repository.js";
import { OAuthUserRepository } from "../../repositories/user.repository.js";
import { AuthorizationRequest } from "../../requests/authorization.request.js";
import { RequestInterface } from "../../requests/request.js";
import { BearerTokenResponse } from "../../responses/bearer_token.response.js";
import { OAuthResponse, ResponseInterface } from "../../responses/response.js";
import { arrayDiff } from "../../utils/array.js";
import { base64decode } from "../../utils/base64.js";
import { DateInterval } from "../../utils/date_interval.js";
import { ExtraAccessTokenFields, JwtInterface } from "../../utils/jwt.js";
import { getSecondsUntil, roundToSeconds } from "../../utils/time.js";
import { GrantIdentifier, GrantInterface } from "./grant.interface.js";

export interface ITokenData {
  iss: undefined;
  sub: string | undefined;
  aud: undefined;
  exp: number;
  nbf: number;
  iat: number;
  jti: string;
  cid: string;
  scope: string;
  [key: string]: unknown;
}

export abstract class AbstractGrant implements GrantInterface {
  protected authCodeRepository?: OAuthAuthCodeRepository;
  protected userRepository?: OAuthUserRepository;
  protected readonly scopeDelimiterString = " ";
  protected readonly supportedGrantTypes: GrantIdentifier[] = [
    "client_credentials",
    "authorization_code",
    "refresh_token",
    "password",
    "implicit",
    "urn:ietf:params:oauth:grant-type:token-exchange",
  ];

  abstract readonly identifier: GrantIdentifier;

  constructor(
    protected readonly clientRepository: OAuthClientRepository,
    protected readonly tokenRepository: OAuthTokenRepository,
    protected readonly scopeRepository: OAuthScopeRepository,
    protected readonly jwt: JwtInterface,
    public readonly options: AuthorizationServerOptions,
  ) {}

  async makeBearerTokenResponse(
    client: OAuthClient,
    accessToken: OAuthToken,
    scopes: OAuthScope[] = [],
    extraJwtFields: ExtraAccessTokenFields = {},
  ) {
    const scope = scopes.map(scope => scope.name).join(this.scopeDelimiterString);

    const encryptedAccessToken = await this.encryptAccessToken(client, accessToken, scopes, extraJwtFields);

    let encryptedRefreshToken: string | undefined = undefined;

    if (accessToken.refreshToken) {
      encryptedRefreshToken = await this.encryptRefreshToken(client, accessToken, scopes);
    }

    const bearerTokenResponse = new BearerTokenResponse(accessToken);

    bearerTokenResponse.body = {
      token_type: "Bearer",
      expires_in: getSecondsUntil(accessToken.accessTokenExpiresAt),
      access_token: encryptedAccessToken,
      refresh_token: encryptedRefreshToken,
      scope,
    };

    return bearerTokenResponse;
  }

  protected encryptRefreshToken(client: OAuthClient, refreshToken: OAuthToken, scopes: OAuthScope[]) {
    const expiresAtMs = refreshToken.refreshTokenExpiresAt?.getTime() ?? refreshToken.accessTokenExpiresAt.getTime();
    return this.encrypt({
      client_id: client.id,
      access_token_id: refreshToken.accessToken,
      refresh_token_id: refreshToken.refreshToken,
      scope: scopes.map(scope => scope.name).join(this.scopeDelimiterString),
      user_id: refreshToken.user?.id,
      expire_time: Math.ceil(expiresAtMs / 1000),
    });
  }

  protected encryptAccessToken(
    client: OAuthClient,
    accessToken: OAuthToken,
    scopes: OAuthScope[],
    extraJwtFields: ExtraAccessTokenFields,
  ) {
    const now = Date.now();
    return this.encrypt(<ITokenData>{
      // optional claims which the `jwtService.extraTokenFields()` method may overwrite
      iss: undefined, // @see https://tools.ietf.org/html/rfc7519#section-4.1.1
      aud: undefined, // @see https://tools.ietf.org/html/rfc7519#section-4.1.3

      // the contents of `jwtService.extraTokenFields()`
      ...extraJwtFields,

      // non-standard claims over which this library asserts control
      cid: client[this.options.tokenCID],
      scope: scopes.map(scope => scope.name).join(this.scopeDelimiterString),

      // standard claims over which this library asserts control
      sub: accessToken.user?.id, // @see https://tools.ietf.org/html/rfc7519#section-4.1.2
      exp: roundToSeconds(accessToken.accessTokenExpiresAt.getTime()), // @see https://tools.ietf.org/html/rfc7519#section-4.1.4
      nbf: roundToSeconds(now) - this.options.notBeforeLeeway, // @see https://tools.ietf.org/html/rfc7519#section-4.1.5
      iat: roundToSeconds(now), // @see https://tools.ietf.org/html/rfc7519#section-4.1.6
      jti: accessToken.accessToken, // @see https://tools.ietf.org/html/rfc7519#section-4.1.7
    });
  }

  protected async validateClient(request: RequestInterface): Promise<OAuthClient> {
    const [clientId, clientSecret] = this.getClientCredentials(request);

    const grantType = this.getGrantType(request);

    const client = await this.clientRepository.getByIdentifier(clientId);

    if (isClientConfidential(client) && !clientSecret) {
      throw OAuthException.invalidClient("Confidential clients require client_secret.");
    }

    const userValidationSuccess = await this.clientRepository.isClientValid(grantType, client, clientSecret);

    if (!userValidationSuccess) {
      throw OAuthException.invalidClient();
    }

    return client;
  }

  protected getClientCredentials(request: RequestInterface): [string, string | undefined] {
    const [basicAuthUser, basicAuthPass] = this.getBasicAuthCredentials(request);

    let clientId = this.getRequestParameter("client_id", request, basicAuthUser);

    if (!clientId) {
      throw OAuthException.invalidParameter("client_id");
    }

    let clientSecret = this.getRequestParameter("client_secret", request, basicAuthPass);

    if (Array.isArray(clientId) && clientId.length > 0) clientId = clientId[0];

    if (Array.isArray(clientSecret) && clientSecret.length > 0) clientSecret = clientSecret[0];

    return [clientId, clientSecret];
  }

  protected getBasicAuthCredentials(request: RequestInterface) {
    if (!request.headers?.hasOwnProperty("authorization")) {
      return [undefined, undefined];
    }

    const header = request.headers["authorization"];

    if (!header || !header.startsWith("Basic ")) {
      return [undefined, undefined];
    }

    const decoded = base64decode(header.substr(6, header.length));

    if (!decoded.includes(":")) {
      return [undefined, undefined];
    }

    return decoded.split(":");
  }

  protected async validateScopes(
    scopes: undefined | string | string[] = [],
    redirectUri?: string,
  ): Promise<OAuthScope[]> {
    if (typeof scopes === "string") {
      scopes = scopes.split(this.scopeDelimiterString);
    }

    if (!scopes || scopes.length === 0 || scopes[0] === "") {
      return [];
    }

    const validScopes = await this.scopeRepository.getAllByIdentifiers(scopes);

    const invalidScopes = arrayDiff(
      scopes,
      validScopes.map(scope => scope.name),
    );

    if (invalidScopes.length > 0) {
      throw OAuthException.invalidScope(invalidScopes.join(", "), redirectUri);
    }

    return validScopes;
  }

  protected async issueAccessToken(
    accessTokenTTL: DateInterval,
    client: OAuthClient,
    user?: OAuthUser | null,
    scopes: OAuthScope[] = [],
  ): Promise<OAuthToken> {
    const accessToken = await this.tokenRepository.issueToken(client, scopes, user);
    accessToken.accessTokenExpiresAt = accessTokenTTL.getEndDate();
    await this.tokenRepository.persist(accessToken);
    return accessToken;
  }

  issueRefreshToken(accessToken: OAuthToken, client: OAuthClient): Promise<OAuthToken> {
    return this.tokenRepository.issueRefreshToken(accessToken, client);
  }

  private getGrantType(request: RequestInterface): GrantIdentifier {
    const result =
      this.getRequestParameter("grant_type", request) ?? this.getQueryStringParameter("grant_type", request);

    if (!result || !this.supportedGrantTypes.includes(result)) {
      throw OAuthException.invalidParameter("grant_type");
    }

    if (this.identifier !== result) {
      throw OAuthException.invalidParameter("grant_type", "something went wrong"); // @todo remove the something went wrong
    }

    return result;
  }

  protected getRequestParameter(param: string, request: RequestInterface, defaultValue?: any) {
    return request.body?.[param] ?? defaultValue;
  }

  protected getQueryStringParameter(param: string, request: RequestInterface, defaultValue?: any) {
    return request.query?.[param] ?? defaultValue;
  }

  protected encrypt(unencryptedData: string | Buffer | Record<string, unknown>): Promise<string> {
    return this.jwt.sign(unencryptedData);
  }

  protected async decrypt(encryptedData: string) {
    return await this.jwt.verify(encryptedData);
  }

  validateAuthorizationRequest(_request: RequestInterface): Promise<AuthorizationRequest> {
    throw new Error("Grant does not support the request");
  }

  canRespondToAccessTokenRequest(request: RequestInterface): boolean {
    return this.getRequestParameter("grant_type", request) === this.identifier;
  }

  canRespondToAuthorizationRequest(_request: RequestInterface): boolean {
    return false;
  }

  canRespondToRevokeRequest(request: RequestInterface): boolean {
    return this.getRequestParameter("token_type_hint", request) === this.identifier;
  }

  async completeAuthorizationRequest(_authorizationRequest: AuthorizationRequest): Promise<ResponseInterface> {
    throw new Error("Grant does not support the request");
  }

  async respondToAccessTokenRequest(_req: RequestInterface, _accessTokenTTL: DateInterval): Promise<ResponseInterface> {
    throw new Error("Grant does not support the request");
  }

  async respondToRevokeRequest(request: RequestInterface): Promise<ResponseInterface> {
    const encryptedToken = this.getRequestParameter("token", request);

    if (!encryptedToken) {
      throw OAuthException.invalidParameter("token");
    }

    await this.doRevoke(encryptedToken);
    return new OAuthResponse();
  }

  protected async doRevoke(_encryptedToken: string): Promise<void> {
    // default: nothing to do, be quiet about it
    return;
  }
}