All files / src/oauth client-auth.ts

100% Statements 35/35
100% Branches 36/36
100% Functions 4/4
100% Lines 32/32

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112            220x 220x                                   220x                       8x 8x 8x 13x 7x 7x         1x           5x 5x 5x                 220x       95x   95x 95x 95x 2x   93x 1x               92x 1x                 91x 91x   82x 82x 75x   7x 7x         4x   3x    
/**
 * Client authentication of the token and revocation endpoints (RFC 6749 section 2.3,
 * RFC 7009 section 2.1).
 */
 
import type { Request } from 'express';
import { createHash, timingSafeEqual } from 'node:crypto';
import { getRegisteredClient } from './endpoints/register';
 
/** Client credentials a request may carry in its form body. */
export interface ClientCredentialParams {
  client_id?: string;
  client_secret?: string;
}
 
export type ClientAuthentication =
  | { authenticated: true; clientId: string | undefined }
  | {
      authenticated: false;
      status: number;
      error: string;
      description: string;
      challenge: boolean;
    };
 
const CLIENT_AUTHENTICATION_FAILED = {
  authenticated: false,
  status: 401,
  error: 'invalid_client',
  description: 'Client authentication failed',
} as const;
 
/**
 * Id and secret of an `Authorization: Basic` header. RFC 6749 section 2.3.1: both are
 * form-urlencoded before base64 encoding. Undefined when the header is malformed.
 */
function basicCredentials(header: string): { id: string; secret: string } | undefined {
  const decoded = Buffer.from(header, 'base64').toString('utf8');
  const separator = decoded.indexOf(':');
  if (separator < 0) return undefined;
  const formDecode = (value: string) => decodeURIComponent(value.replaceAll('+', ' '));
  try {
    return {
      id: formDecode(decoded.slice(0, separator)),
      secret: formDecode(decoded.slice(separator + 1)),
    };
  } catch {
    return undefined;
  }
}
 
/** Constant-time comparison of a presented secret with the registered one. */
function secretMatches(presented: string, registered: string): boolean {
  const a = createHash('sha256').update(presented).digest();
  const b = createHash('sha256').update(registered).digest();
  return timingSafeEqual(a, b);
}
 
/**
 * Authenticate the client of a request (RFC 6749 section 2.3). A client registered with a
 * secret (client_secret_basic or client_secret_post) must present it, in the Authorization
 * header or the form, never both (section 2.3). A public client, registered with `none` or
 * not registered (the device flow), identifies itself with client_id.
 */
export async function authenticateClient(
  req: Request,
  params: ClientCredentialParams,
): Promise<ClientAuthentication> {
  const header = req.headers?.authorization;
  const basicHeader =
    typeof header === 'string' && /^basic /i.test(header) ? header.slice(6).trim() : undefined;
  const basic = basicHeader === undefined ? undefined : basicCredentials(basicHeader);
  if (basicHeader !== undefined && !basic) {
    return { ...CLIENT_AUTHENTICATION_FAILED, challenge: true };
  }
  if (basic && params.client_secret !== undefined) {
    return {
      authenticated: false,
      status: 400,
      error: 'invalid_request',
      description: 'Use one client authentication method',
      challenge: false,
    };
  }
  if (basic && params.client_id !== undefined && params.client_id !== basic.id) {
    return {
      authenticated: false,
      status: 400,
      error: 'invalid_request',
      description: 'client_id does not match the Authorization header',
      challenge: false,
    };
  }
 
  const clientId = basic?.id ?? params.client_id;
  if (clientId === undefined) return { authenticated: true, clientId };
 
  const registered = await getRegisteredClient(clientId);
  if (!registered || registered.token_endpoint_auth_method === 'none') {
    return { authenticated: true, clientId };
  }
  const presented = basic?.secret ?? params.client_secret;
  if (
    presented === undefined ||
    registered.client_secret === undefined ||
    !secretMatches(presented, registered.client_secret)
  ) {
    return { ...CLIENT_AUTHENTICATION_FAILED, challenge: basic !== undefined };
  }
  return { authenticated: true, clientId };
}