Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions examples/oidc/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { serve } from "https://deno.land/std@0.161.0/http/server.ts";
import {
Cookie,
deleteCookie,
getCookies,
setCookie,
} from "https://deno.land/std@0.161.0/http/cookie.ts";
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
import { OIDCClient } from "../../src/oidc/oidc_client.ts";

const jwks = jose.createRemoteJWKSet(
new URL("https://www.googleapis.com/oauth2/v3/certs"),
);

const oidcClient = new OIDCClient({
clientId: Deno.env.get("CLIENT_ID")!,
clientSecret: Deno.env.get("CLIENT_SECRET")!,
authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUri: "https://oauth2.googleapis.com/token",
redirectUri: "http://localhost:8000/oauth2/callback",
userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
defaults: {
scope: ["openid", "email", "profile"],
},
verifyJwt: (jwt) => jose.jwtVerify(jwt, jwks),
});

/** This is where we'll store our state and PKCE codeVerifiers */
const loginStates = new Map<string, { state: string; codeVerifier: string }>();
/** The name we'll use for the session cookie */
const cookieName = "session";

/** Handles incoming HTTP requests */
function handler(req: Request): Promise<Response> | Response {
const url = new URL(req.url);
const path = url.pathname;

switch (path) {
case "/login":
return redirectToAuthEndpoint();
case "/oauth2/callback":
return handleCallback(req);
default:
return new Response("Not Found", { status: 404 });
}
}

async function redirectToAuthEndpoint(): Promise<Response> {
// Generate a random state
const state = crypto.randomUUID();

const { uri, codeVerifier } = await oidcClient.code.getAuthorizationUri({
state,
});

// Associate the state and PKCE codeVerifier with a session cookie
const sessionId = crypto.randomUUID();
loginStates.set(sessionId, { state, codeVerifier });
const sessionCookie: Cookie = {
name: cookieName,
value: sessionId,
httpOnly: true,
sameSite: "Lax",
};
const headers = new Headers({ Location: uri.toString() });
setCookie(headers, sessionCookie);

// Redirect to the authorization endpoint
return new Response(null, { status: 302, headers });
}

async function handleCallback(req: Request): Promise<Response> {
// Load the state and PKCE codeVerifier associated with the session
const sessionCookie = getCookies(req.headers)[cookieName];
const loginState = sessionCookie && loginStates.get(sessionCookie);
if (!loginState) {
throw new Error("invalid session");
}
loginStates.delete(sessionCookie);

// Exchange the authorization code for an access token
const tokens = await oidcClient.code.getToken(req.url, loginState);

const userInfo = await oidcClient.getUserInfo(
tokens.accessToken,
tokens.idToken,
);

// Clear the session cookie since we don't need it anymore
const headers = new Headers();
deleteCookie(headers, cookieName);
return new Response(
`<!DOCTYPE html><html><body><h1>Hello, ${userInfo.name}!</h1></body></html>`,
{
headers: {
"content-type": "text/html",
},
},
);
}

// Start the app
serve(handler, { port: 8000 });
107 changes: 107 additions & 0 deletions examples/oidc/oak.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import {
MemoryStore,
Session,
} from "https://deno.land/x/oak_sessions@v4.0.5/mod.ts";
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
import { OIDCClient } from "../../src/oidc/oidc_client.ts";

const jwks = jose.createRemoteJWKSet(
new URL("https://www.googleapis.com/oauth2/v3/certs"),
);

const oidcClient = new OIDCClient({
clientId: Deno.env.get("CLIENT_ID")!,
clientSecret: Deno.env.get("CLIENT_SECRET")!,
authorizationEndpointUri: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUri: "https://oauth2.googleapis.com/token",
redirectUri: "http://localhost:8000/oauth2/callback",
userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
defaults: {
scope: ["openid", "email", "profile"],
},
verifyJwt: (jwt) => jose.jwtVerify(jwt, jwks),
});

type AppState = {
session: Session;
};

const router = new Router<AppState>();
router.get("/login", async (ctx) => {
// Generate a random state for this login event
const state = crypto.randomUUID();

// Construct the URL for the authorization redirect and get a PKCE codeVerifier
const { uri, codeVerifier } = await oidcClient.code.getAuthorizationUri({
state,
});

// Store both the state and codeVerifier in the user session
ctx.state.session.flash("state", state);
ctx.state.session.flash("codeVerifier", codeVerifier);

// Redirect the user to the authorization endpoint
ctx.response.redirect(uri);
});
router.get("/oauth2/callback", async (ctx) => {
// Make sure both a state and codeVerifier are present for the user's session
const state = ctx.state.session.get("state");
if (typeof state !== "string") {
throw new Error("invalid state");
}

const codeVerifier = ctx.state.session.get("codeVerifier");
if (typeof codeVerifier !== "string") {
throw new Error("invalid codeVerifier");
}

// Exchange the authorization code for an access token
const tokens = await oidcClient.code.getToken(ctx.request.url, {
state,
codeVerifier,
});

// Use the userinfo endpoint to get more information about the user
const userInfo = await oidcClient.getUserInfo(
tokens.accessToken,
tokens.idToken,
);

ctx.response.headers.set("Content-Type", "text/html");
ctx.response.body =
`<!DOCTYPE html><html><body><h1>Hello, ${userInfo.name}!</h1></body></html>`;
});

const app = new Application<AppState>();

// Add a key for signing cookies
app.keys = ["super-secret-key"];

// Set up the session middleware
const sessionStore = new MemoryStore();
app.use(Session.initMiddleware(sessionStore, {
cookieSetOptions: {
httpOnly: true,
sameSite: "lax",
// Enable for when running outside of localhost
// secure: true,
signed: true,
},
cookieGetOptions: {
signed: true,
},
expireAfterSeconds: 60 * 10,
}));

// Mount the router
app.use(router.allowedMethods(), router.routes());

// Start the app
const port = 8000;
app.addEventListener("listen", () => {
console.log(
`App listening on port ${port}. Navigate to http://localhost:${port}/login to log in!`,
);
});
await app.listen({ port });
18 changes: 10 additions & 8 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,30 @@ export {
export { OAuth2Client } from "./src/oauth2_client.ts";
export type { OAuth2ClientConfig } from "./src/oauth2_client.ts";

export { AuthorizationCodeGrant } from "./src/authorization_code_grant.ts";
export type {
AuthorizationCodeGrant,
AuthorizationCodeTokenOptions,
AuthorizationUri,
AuthorizationUriOptions,
AuthorizationUriOptionsWithoutPKCE,
AuthorizationUriOptionsWithPKCE,
AuthorizationUriWithoutVerifier,
AuthorizationUriWithVerifier,
} from "./src/authorization_code_grant.ts";
export { ClientCredentialsGrant } from "./src/client_credentials_grant.ts";
export type {
ClientCredentialsGrant,
ClientCredentialsTokenOptions,
} from "./src/client_credentials_grant.ts";
export { ImplicitGrant } from "./src/implicit_grant.ts";
export type {
ImplicitGrant,
ImplicitTokenOptions,
ImplicitUriOptions,
} from "./src/implicit_grant.ts";
export type {
export {
ResourceOwnerPasswordCredentialsGrant,
ResourceOwnerPasswordCredentialsTokenOptions,
} from "./src/resource_owner_password_credentials.ts";
export type {
RefreshTokenGrant,
RefreshTokenOptions,
} from "./src/refresh_token_grant.ts";
ResourceOwnerPasswordCredentialsTokenOptions,
} from "./src/resource_owner_password_credentials.ts";
export { RefreshTokenGrant } from "./src/refresh_token_grant.ts";
export type { RefreshTokenOptions } from "./src/refresh_token_grant.ts";
13 changes: 13 additions & 0 deletions oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type {
IDToken,
JWTHeaderParameters,
JWTPayload,
JWTVerifyResult,
OIDCTokens,
} from "./src/oidc/types.ts";

export { OIDCClient, UserInfoError } from "./src/oidc/oidc_client.ts";
export type { OIDCClientConfig } from "./src/oidc/oidc_client.ts";

export { AuthorizationCodeFlow } from "./src/oidc/authorization_code_flow.ts";
export type { OIDCAuthorizationUriOptions } from "./src/oidc/authorization_code_flow.ts";
47 changes: 24 additions & 23 deletions src/authorization_code_grant.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { OAuth2Client } from "./oauth2_client.ts";
import type { OAuth2ClientConfig } from "./oauth2_client.ts";
import { AuthorizationResponseError, OAuth2ResponseError } from "./errors.ts";
import type { RequestOptions, Tokens } from "./types.ts";
import { OAuth2GrantBase } from "./grant_base.ts";
import { createPkceChallenge } from "./pkce.ts";

interface AuthorizationUriOptionsWithPKCE {
export interface AuthorizationUriOptionsWithPKCE {
/**
* State parameter to send along with the authorization request.
*
Expand All @@ -22,7 +22,7 @@ interface AuthorizationUriOptionsWithPKCE {
disablePkce?: false;
}

type AuthorizationUriOptionsWithoutPKCE =
export type AuthorizationUriOptionsWithoutPKCE =
& Omit<AuthorizationUriOptionsWithPKCE, "disablePkce">
& { disablePkce: true };

Expand Down Expand Up @@ -72,8 +72,8 @@ export interface AuthorizationCodeTokenOptions {
* See https://tools.ietf.org/html/rfc6749#section-4.1
*/
export class AuthorizationCodeGrant extends OAuth2GrantBase {
constructor(client: OAuth2Client) {
super(client);
constructor(config: OAuth2ClientConfig) {
super(config);
}

/**
Expand All @@ -98,11 +98,11 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {
): Promise<AuthorizationUri> {
const params = new URLSearchParams();
params.set("response_type", "code");
params.set("client_id", this.client.config.clientId);
if (typeof this.client.config.redirectUri === "string") {
params.set("redirect_uri", this.client.config.redirectUri);
params.set("client_id", this.config.clientId);
if (typeof this.config.redirectUri === "string") {
params.set("redirect_uri", this.config.redirectUri);
}
const scope = options.scope ?? this.client.config.defaults?.scope;
const scope = options.scope ?? this.config.defaults?.scope;
if (scope) {
params.set("scope", Array.isArray(scope) ? scope.join(" ") : scope);
}
Expand All @@ -112,15 +112,15 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {

if (options.disablePkce === true) {
return {
uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri),
uri: new URL(`?${params}`, this.config.authorizationEndpointUri),
};
}

const challenge = await createPkceChallenge();
params.set("code_challenge", challenge.codeChallenge);
params.set("code_challenge_method", challenge.codeChallengeMethod);
return {
uri: new URL(`?${params}`, this.client.config.authorizationEndpointUri),
uri: new URL(`?${params}`, this.config.authorizationEndpointUri),
codeVerifier: challenge.codeVerifier,
};
}
Expand Down Expand Up @@ -149,15 +149,16 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {

const accessTokenResponse = await fetch(request);

return this.parseTokenResponse(accessTokenResponse);
const { tokens } = await this.parseTokenResponse(accessTokenResponse);
return tokens;
}

private async validateAuthorizationResponse(
protected async validateAuthorizationResponse(
url: URL,
options: AuthorizationCodeTokenOptions,
): Promise<{ code: string; state?: string }> {
if (typeof this.client.config.redirectUri === "string") {
const expectedUrl = new URL(this.client.config.redirectUri);
if (typeof this.config.redirectUri === "string") {
const expectedUrl = new URL(this.config.redirectUri);

if (
typeof url.pathname === "string" &&
Expand Down Expand Up @@ -191,7 +192,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {
const state = params.get("state");
const stateValidator = options.stateValidator ||
(options.state && ((s) => s === options.state)) ||
this.client.config.defaults?.stateValidator;
this.config.defaults?.stateValidator;

if (stateValidator && !await stateValidator(state)) {
if (state === null) {
Expand All @@ -209,7 +210,7 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {
return { code };
}

private buildAccessTokenRequest(
protected buildAccessTokenRequest(
code: string,
codeVerifier?: string,
requestOptions: RequestOptions = {},
Expand All @@ -226,20 +227,20 @@ export class AuthorizationCodeGrant extends OAuth2GrantBase {
body.code_verifier = codeVerifier;
}

if (typeof this.client.config.redirectUri === "string") {
body.redirect_uri = this.client.config.redirectUri;
if (typeof this.config.redirectUri === "string") {
body.redirect_uri = this.config.redirectUri;
}

if (typeof this.client.config.clientSecret === "string") {
if (typeof this.config.clientSecret === "string") {
// We have a client secret, authenticate using HTTP Basic Auth as described in RFC6749 Section 2.3.1.
const { clientId, clientSecret } = this.client.config;
const { clientId, clientSecret } = this.config;
headers.Authorization = `Basic ${btoa(`${clientId}:${clientSecret}`)}`;
} else {
// This appears to be a public client, include the client ID along in the body
body.client_id = this.client.config.clientId;
body.client_id = this.config.clientId;
}

return this.buildRequest(this.client.config.tokenUri, {
return this.buildRequest(this.config.tokenUri, {
method: "POST",
headers,
body,
Expand Down
Loading