Skip to content

Commit

Permalink
Merge pull request #3 from farbenmeer/feat/auto-redirect-to-login
Browse files Browse the repository at this point in the history
feat: redirect on refresh failed, support login page, mild refactoring
  • Loading branch information
noramass committed Jun 7, 2023
2 parents 33e44f4 + dc75573 commit 6cbde22
Show file tree
Hide file tree
Showing 15 changed files with 184 additions and 92 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,5 @@ dist
.vscode

.npmrc

**/.turbo
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "major",
"comment": "feat: redirect on refresh failed, support login page, mild refactoring",
"packageName": "@farbenmeer/deadbolt",
"email": "[email protected]",
"dependentChangeType": "patch"
}
115 changes: 39 additions & 76 deletions packages/deadbolt/src/oauth2.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,29 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
import { randomState } from "src/plugins";
import {
OAuth2Props,
OAuth2FlowContext,
OAuth2Plugin,
OAuth2PluginHook,
OAuth2ProviderData,
OAuth2RequestContext,
OAuth2Config,
OAuth2ProviderDataMap,
OAuth2PluginInit,
AnyRequest,
AnyResponse,
} from "src/types";
import { cookieUtil } from "src/util/cookieUtil";
import { encryption, getRandomString } from "src/util/encryption";
import { cookieUtil, pluginHooks, initConfig } from "src/util";
import { redirect } from "src/util/redirect";

function initConfig(config: OAuth2Config, defaultProvider?: string): Required<OAuth2Config> {
const copy = { defaultProvider, ...config };

copy.crypto ??= crypto;
if (!copy.encrypt || !copy.decrypt) {
const { encrypt, decrypt } = encryption({
algorithm: "AES-GCM",
ivLength: 12,
hash: "SHA-256",
key: copy.secret,
crypto: copy.crypto,
});
copy.encrypt = encrypt;
copy.decrypt = decrypt;
}
// todo: signatures

return copy as never;
}

export function oauth2<Data>({ plugins = [], providers, config }: OAuth2Props) {
plugins.push((({ crypto }) => ({
generateState(context) {
context.flow.state = btoa(getRandomString(24, crypto));
},
})) as OAuth2PluginInit);
const initialisedConfig = initConfig(
config,
providers.length === 1 ? providers[0].name : undefined,
);
const initialisedPlugins = plugins.map(it =>
typeof it === "function" ? it(initialisedConfig) : it,
);

function hook<Hook extends keyof OAuth2Plugin>(
hook: Hook,
reverse = false,
): Required<OAuth2Plugin>[Hook] {
const hooks = initialisedPlugins
.map(plugin => plugin[hook]?.bind(plugin) as OAuth2PluginHook)
.filter(it => it);
if (reverse) hooks.reverse();
return async context => {
for (const hook of hooks) if ((await hook(context)) === true) return true;
return false;
};
}

const storeData = hook("storeData");
const retrieveData = hook("retrieveData");
const storeState = hook("storeState");
const retrieveState = hook("retrieveState");
const generateState = hook("generateState");
const reviveState = hook("reviveState", true);
export function oauth2<Data>({ plugins = [], providers, config: initialConfig }: OAuth2Props) {
plugins.push(randomState());
const config = initConfig(initialConfig, providers);
const hooks = pluginHooks(plugins, config, {
storeData: false,
retrieveData: false,
storeState: false,
retrieveState: false,
generateState: false,
reviveState: true,
});

function buildContext(req: AnyRequest, res?: AnyResponse, args?: string[]): OAuth2RequestContext {
const [provider, step] = args ?? ((req as any).query.args as string[]);
Expand All @@ -79,7 +33,6 @@ export function oauth2<Data>({ plugins = [], providers, config }: OAuth2Props) {
const flow: OAuth2FlowContext = { code, state, referer, provider, step: realStep };
const connected: Record<string, OAuth2ProviderData<unknown>> = {};
const providerConf = providers.find(it => it.name === flow.provider);
const config = initialisedConfig;
const cookies = cookieUtil(req, res);
return { req, res, flow, connected, config, cookies, provider: providerConf };
}
Expand All @@ -92,60 +45,66 @@ export function oauth2<Data>({ plugins = [], providers, config }: OAuth2Props) {

switch (context.flow.step) {
case "authorize":
await generateState(context);
await storeState(context);
await hooks.generateState(context);
await hooks.storeState(context);
await provider.authorize(context);
break;
case "exchange":
{
await retrieveState(context);
await hooks.retrieveState(context);
// allow proxy redirect process cancellation
if (await reviveState(context)) return;
if (await hooks.reviveState(context)) return;
await provider.exchange(context);
await provider.loadData?.(context);
const referer = context.flow.referer ?? "/";
context.flow.state = context.flow.code = context.flow.referer = undefined;
await storeData(context);
await storeState(context);
await hooks.storeData(context);
await hooks.storeState(context);
res.redirect(referer);
}
break;
case "refresh":
await refreshIfNecessary(context, provider.name);
break;
case "logout":
await retrieveData(context);
await hooks.retrieveData(context);
await provider.revoke?.(context);
delete context.connected[context.flow.provider];
await storeData(context);
await hooks.storeData(context);
break;
}
}

async function refreshIfNecessary(context: OAuth2RequestContext, providerName?: string) {
async function refreshIfNecessary(
context: OAuth2RequestContext,
providerName?: string,
redirectToLoginPage = false,
) {
const currentName = context.flow.provider;
const currentProvider = context.provider;
for (const provider of providers.filter(it =>
providerName ? it.name === providerName : true,
)) {
context.flow.provider = provider.name;
context.provider = provider;
await retrieveData(context);
await hooks.retrieveData(context);
if (!provider.refresh || !context.connected[provider.name]) continue;
const data = context.connected[provider.name]!;
if (data.refreshTokenExpires && data.refreshTokenExpires <= new Date()) return;
if (data.accessTokenExpires && data.accessTokenExpires <= new Date()) {
await provider.refresh(context);
await storeData(context);
await hooks.storeData(context);
} else if (!data.accessTokenExpires) {
try {
await provider.loadData?.(context);
} catch {
await provider.refresh(context);
await storeData(context);
await hooks.storeData(context);
}
}
}
if (redirectToLoginPage && providerName && !context.connected[providerName])
redirect({ context, url: config.loginPageUrl });
context.flow.provider = currentName;
context.provider = currentProvider;
}
Expand All @@ -160,20 +119,24 @@ export function oauth2<Data>({ plugins = [], providers, config }: OAuth2Props) {
const context = buildContext(req, res, [providerName, "refresh"]);
await refreshIfNecessary(context);
if (!context.provider) return undefined;
await retrieveData(context);
await hooks.retrieveData(context);
if (!context.connected[context.provider.name].data) await context.provider.loadData?.(context);
return context.connected[providerName];
}

function authorized<Key extends keyof Data & string>(providerName: Key, identifier?: string) {
function authorized<Key extends keyof Data & string>(
providerName: Key,
identifier?: string,
redirectToLoginPage = true,
) {
if (identifier && providerName) providerName = `${providerName}.${identifier}` as any;
return async (req: NextRequest) => {
const error = req.nextUrl.clone();
error.pathname = "/401";
const context = buildContext(req, undefined, [providerName!, "refresh"]);
if (!context.provider) return NextResponse.rewrite(error, { status: 401 });
try {
await refreshIfNecessary(context, providerName);
await refreshIfNecessary(context, providerName, redirectToLoginPage);
await context.provider.loadData?.(context);
if (context.cookies.dirty()) context.cookies.apply(req);
} catch (e) {
Expand Down
20 changes: 18 additions & 2 deletions packages/deadbolt/src/plugins/cookie-storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
import { OAuth2PluginInit, OAuth2ProviderData } from "src/types";
import { codedError } from "src/util/error";

export interface CookieStorageOptions {
name?: string;
Expand All @@ -12,12 +13,25 @@ export function cookieStorage({
cookie,
saveData = true,
}: CookieStorageOptions = {}): OAuth2PluginInit {
function cookieLengthCheck(max = 4096) {
let length = 0;
return function (addition: string) {
if ((length += addition.length) > max)
throw codedError(
"Cookie length exceeds 4096 bytes, consider using externalStorage instead",
"COOKIE_TOO_LONG",
);
return addition;
};
}

return ({ encrypt, decrypt }) => ({
async storeState({ flow, cookies }) {
const { state, referer, provider } = flow;
const value = JSON.stringify({ state, referer, provider });
const check = cookieLengthCheck();
if (!state) cookies.set(`${name}.state`, "");
else cookies.set(`${name}.state`, (await encrypt(value)) as string, cookie);
else cookies.set(`${name}.state`, check(await encrypt(value)), cookie);
},

async retrieveState(context) {
Expand All @@ -34,10 +48,12 @@ export function cookieStorage({

async storeData({ connected, cookies }) {
if (!saveData) return;
const check = cookieLengthCheck();
for (const [provider, data] of Object.entries(connected)) {
if (!data) continue;
const { data: _, ...state } = data;
cookies.set(`${name}.data.${provider}`, await encrypt(JSON.stringify(state)), {
const encrypted = await encrypt(JSON.stringify(state));
cookies.set(`${name}.data.${provider}`, check(encrypted), {
expires: state.refreshTokenExpires,
...cookie,
});
Expand Down
10 changes: 10 additions & 0 deletions packages/deadbolt/src/plugins/default-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { OAuth2PluginInit } from "src/types";
import { getRandomString } from "src/util";

export function randomState(length = 24): OAuth2PluginInit {
return ({ crypto }) => ({
generateState(context) {
context.flow.state = btoa(getRandomString(length, crypto));
},
});
}
1 change: 1 addition & 0 deletions packages/deadbolt/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./cookie-storage";
export * from "./external-storage";
export * from "./state-proxy";
export * from "./default-state";
31 changes: 18 additions & 13 deletions packages/deadbolt/src/providers/azure.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { buildUrl, HttpClient } from "@farbenmeer/http";
import { HttpClient } from "@farbenmeer/http";
import { OAuth2Provider, OAuth2ProviderData, OAuth2RequestContext, PromiseOr } from "src/types";
import { redirect } from "src/util/redirect";

export interface AzureProviderConfig {
tenant: string;
clientId: string;
clientSecret: string;
scope?: string;

loadData?(
context: OAuth2RequestContext,
config: { tenant: string } & OAuth2ProviderData<any>,
Expand All @@ -20,6 +22,8 @@ export interface AzureTokenResponse {
error?: string;
}

const THREE_MONTHS = 7776000000;

export function azureProvider(config: AzureProviderConfig): OAuth2Provider {
const { clientId, clientSecret, tenant, scope = `${clientId}/.default`, loadData } = config;
const client = new HttpClient({
Expand All @@ -29,19 +33,19 @@ export function azureProvider(config: AzureProviderConfig): OAuth2Provider {

return {
name: `azure.${tenant}`,
async authorize({ config, res, flow }) {
async authorize(context) {
const { config, flow } = context;
const url = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`;
const redirectUri = `${config.baseUrl}/azure.${tenant}/authorize`;
if (!res || !("redirect" in res)) return;
res.redirect(
buildUrl(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, undefined, {
response_type: "code",
redirect_uri: redirectUri,
client_id: clientId,
state: flow.state,
scope,
}).toString(),
);
return true;
const params = {
response_type: "code",
redirect_uri: redirectUri,
client_id: clientId,
state: flow.state,
scope,
};
if (redirect({ context, url, params })) return true;
else return;
},

async exchange({ config, flow, connected }) {
Expand All @@ -60,6 +64,7 @@ export function azureProvider(config: AzureProviderConfig): OAuth2Provider {
connected[`azure.${tenant}`] = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
refreshTokenExpires: data.refresh_token ? new Date(+now + THREE_MONTHS) : undefined,
tokenType: data.token_type ?? "Bearer",
accessTokenExpires: new Date(+now + data.expires_in * 1000),
};
Expand Down
1 change: 1 addition & 0 deletions packages/deadbolt/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface OAuth2Config {
secret: string;
defaultProvider?: string;
crypto?: Crypto;
loginPageUrl?: string;

encrypt?(data: string): PromiseOr<string>;

Expand Down
27 changes: 27 additions & 0 deletions packages/deadbolt/src/util/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { OAuth2Config, OAuth2Provider } from "src/types";
import { encryption } from "src/util/encryption";

export function initConfig(
config: OAuth2Config,
providers: OAuth2Provider[],
): Required<OAuth2Config> {
const defaultProvider = config.defaultProvider ?? providers[0]?.name;
const copy = { defaultProvider, crypto, ...config };

if (!copy.encrypt || !copy.decrypt) {
const { encrypt, decrypt } = encryption({
algorithm: "AES-GCM",
ivLength: 12,
hash: "SHA-256",
key: copy.secret,
crypto: copy.crypto,
});
Object.assign(copy, { encrypt, decrypt });
}

if (!copy.loginPageUrl) copy.loginPageUrl = `${copy.baseUrl}/${defaultProvider}/authorize`;

// todo: signatures

return copy as never;
}
3 changes: 3 additions & 0 deletions packages/deadbolt/src/util/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function codedError(message: string, code: string, error = Error): Error & { code: string } {
return Object.assign(new error(message), { code });
}
Loading

0 comments on commit 6cbde22

Please sign in to comment.