Skip to content

Commit

Permalink
Add OIDClient.getUserInfo method
Browse files Browse the repository at this point in the history
  • Loading branch information
cmd-johnson committed Aug 2, 2023
1 parent 80952e9 commit a4d082b
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 18 deletions.
25 changes: 8 additions & 17 deletions src/oidc/authorization_code_flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import { TokenResponseError } from "../errors.ts";
import { OIDCClientConfig } from "./oidc_client.ts";
import { IDToken, JWTPayload, OIDCTokens } from "./types.ts";
import { encode as base64Encode } from "https://deno.land/[email protected]/encoding/base64.ts";
import {
isNumber,
isString,
isStringArray,
isStringOrStringArray,
optionallyIncludesClaim,
} from "./validation.ts";

type ValueOrArray<T> = T | T[];
function valueOrArrayToArray<T>(
Expand Down Expand Up @@ -297,19 +304,6 @@ export class AuthorizationCodeFlow extends AuthorizationCodeGrant {
}
}

function isString(v: unknown): v is string {
return typeof v === "string";
}
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(isString);
}
function isStringOrStringArray(v: unknown): v is string | string[] {
return Array.isArray(v) ? v.every(isString) : isString(v);
}
function isNumber(v: unknown): v is number {
return typeof v === "number";
}

function requireIDTokenClaim<
P extends Record<string, unknown>,
K extends string,
Expand Down Expand Up @@ -343,10 +337,7 @@ function requireOptionalIDTokenClaim<
isValid: (value: unknown) => value is T,
tokenResponse: Response,
): asserts payload is P & { [Key in K]?: T } {
if (!(key in payload)) {
return;
}
if (!isValid(payload[key])) {
if (!optionallyIncludesClaim(payload, key, isValid)) {
throw new TokenResponseError(
`id_token contains an invalid ${key} claim`,
tokenResponse,
Expand Down
108 changes: 107 additions & 1 deletion src/oidc/oidc_client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { OAuth2ClientConfig } from "../oauth2_client.ts";
import { AuthorizationCodeFlow } from "./authorization_code_flow.ts";
import { JWTVerifyResult } from "./types.ts";
import { IDToken, JWTVerifyResult } from "./types.ts";
import { includesClaim, isObject } from "./validation.ts";

export interface OIDCClientConfig extends OAuth2ClientConfig {
/** The URI of the client's redirection endpoint (sometimes also called callback URI). */
redirectUri: string;

/** The UserInfo endpoint of the authorization server. */
userInfoEndpoint?: string;

/**
* Validates and parses the given JWT.
*
Expand All @@ -30,4 +34,106 @@ export class OIDCClient {
) {
this.code = new AuthorizationCodeFlow(this.config);
}

async getUserInfo(
accessToken: string,
idToken: IDToken,
options: { requestHeaders?: HeadersInit } = {},
) {
if (typeof this.config.userInfoEndpoint !== "string") {
throw new UserInfoError(
"calling getUserInfo() requires a userInfoEndpoint to be configured",
);
}
const requestHeaders = new Headers(options.requestHeaders);
requestHeaders.set("Authorization", `Bearer ${accessToken}`);
const response = await fetch(this.config.userInfoEndpoint, {
headers: requestHeaders,
});

if (!response.ok) {
// TODO: parse error response (https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.3)
throw new UserInfoError("userinfo returned an error");
}

const userInfoPayload = await this.getUserInfoResponsePayload(
response.clone(),
);

if (
!includesClaim(
userInfoPayload,
"sub",
(sub): sub is string => sub === idToken.sub,
)
) {
throw new UserInfoError(
"the userInfo response body contained an invalid `sub` claim",
);
}

return userInfoPayload;
}

protected async getUserInfoResponsePayload(
response: Response,
): Promise<Record<string, unknown>> {
const contentType = response.headers.get("Content-Type");
const jsonContentType = "application/json";
const jwtContentType = "application/jwt";

switch (contentType) {
case jsonContentType: {
let responseBody: unknown;
try {
responseBody = await response.json();
} catch {
throw new UserInfoError(
"the userinfo response body was not valid JSON",
);
}
if (!isObject(responseBody)) {
throw new UserInfoError(
"the userinfo response body was not a JSON object",
);
}
return responseBody;
}
case jwtContentType: {
let responseBody: string;
try {
responseBody = await response.text();
} catch {
throw new UserInfoError(`failed to read ${jwtContentType} response`);
}

try {
const { payload } = await this.config.verifyJwt(responseBody);
return payload;
} catch {
throw new UserInfoError(
`failed to validate the userinfo JWT response`,
);
}
}
default:
throw new UserInfoError(
`the userinfo response had an invalid content-type. Expected ${jsonContentType} or ${jwtContentType}, but got ${contentType}`,
);
}
}
}

/** Thrown when trying to call getUserInfo() without a configured userInfoEndpoint */
export class MissingUserInfoEndpointError extends Error {
constructor() {
super("calling getUserInfo() requires a userInfoEndpoint to be configured");
}
}

/** Thrown when there was an error while requesting data from the UserInfo endpoint */
export class UserInfoError extends Error {
constructor(message: string) {
super(message);
}
}
38 changes: 38 additions & 0 deletions src/oidc/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export function isString(v: unknown): v is string {
return typeof v === "string";
}
export function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(isString);
}
export function isStringOrStringArray(v: unknown): v is string | string[] {
return Array.isArray(v) ? v.every(isString) : isString(v);
}
export function isNumber(v: unknown): v is number {
return typeof v === "number";
}
export function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}

export function includesClaim<
P extends Record<string, unknown>,
K extends string,
T,
>(
payload: P,
key: K,
isValid: (value: unknown) => value is T,
): payload is P & { [Key in K]: T } {
return (key in payload) && isValid(payload[key]);
}
export function optionallyIncludesClaim<
P extends Record<string, unknown>,
K extends string,
T,
>(
payload: P,
key: K,
isValid: (value: unknown) => value is T,
): payload is P & { [Key in K]?: T } {
return !(key in payload) || isValid(payload[key]);
}

0 comments on commit a4d082b

Please sign in to comment.