mirror of https://github.com/boxyhq/jackson.git
296 lines
8.4 KiB
TypeScript
296 lines
8.4 KiB
TypeScript
import crypto from 'crypto';
|
|
import {
|
|
IOAuthController,
|
|
JacksonOption,
|
|
OAuthReqBody,
|
|
OAuthTokenReq,
|
|
OAuthTokenRes,
|
|
Profile,
|
|
SAMLResponsePayload,
|
|
Storable,
|
|
} from '../typings';
|
|
import * as dbutils from '../db/utils';
|
|
import saml from '../saml/saml';
|
|
import { JacksonError } from './error';
|
|
import * as allowed from './oauth/allowed';
|
|
import * as codeVerifier from './oauth/code-verifier';
|
|
import * as redirect from './oauth/redirect';
|
|
import { IndexNames } from './utils';
|
|
|
|
const relayStatePrefix = 'boxyhq_jackson_';
|
|
|
|
function getEncodedClientId(client_id: string): { tenant: string | null; product: string | null } | null {
|
|
try {
|
|
const sp = new URLSearchParams(client_id);
|
|
const tenant = sp.get('tenant');
|
|
const product = sp.get('product');
|
|
if (tenant && product) {
|
|
return {
|
|
tenant: sp.get('tenant'),
|
|
product: sp.get('product'),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class OAuthController implements IOAuthController {
|
|
private configStore: Storable;
|
|
private sessionStore: Storable;
|
|
private codeStore: Storable;
|
|
private tokenStore: Storable;
|
|
private opts: JacksonOption;
|
|
|
|
constructor({ configStore, sessionStore, codeStore, tokenStore, opts }) {
|
|
this.configStore = configStore;
|
|
this.sessionStore = sessionStore;
|
|
this.codeStore = codeStore;
|
|
this.tokenStore = tokenStore;
|
|
this.opts = opts;
|
|
}
|
|
|
|
public async authorize(body: OAuthReqBody): Promise<{ redirect_url: string }> {
|
|
const {
|
|
response_type = 'code',
|
|
client_id,
|
|
redirect_uri,
|
|
state,
|
|
tenant,
|
|
product,
|
|
code_challenge,
|
|
code_challenge_method = '',
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
provider = 'saml',
|
|
} = body;
|
|
|
|
if (!redirect_uri) {
|
|
throw new JacksonError('Please specify a redirect URL.', 400);
|
|
}
|
|
|
|
if (!state) {
|
|
throw new JacksonError('Please specify a state to safeguard against XSRF attacks.', 400);
|
|
}
|
|
|
|
let samlConfig;
|
|
|
|
if (tenant && product) {
|
|
const samlConfigs = await this.configStore.getByIndex({
|
|
name: IndexNames.TenantProduct,
|
|
value: dbutils.keyFromParts(tenant, product),
|
|
});
|
|
|
|
if (!samlConfigs || samlConfigs.length === 0) {
|
|
throw new JacksonError('SAML configuration not found.', 403);
|
|
}
|
|
|
|
// TODO: Support multiple matches
|
|
samlConfig = samlConfigs[0];
|
|
} else if (client_id && client_id !== '' && client_id !== 'undefined' && client_id !== 'null') {
|
|
// if tenant and product are encoded in the client_id then we parse it and check for the relevant config(s)
|
|
const sp = getEncodedClientId(client_id);
|
|
if (sp?.tenant) {
|
|
const samlConfigs = await this.configStore.getByIndex({
|
|
name: IndexNames.TenantProduct,
|
|
value: dbutils.keyFromParts(sp.tenant, sp.product || ''),
|
|
});
|
|
|
|
if (!samlConfigs || samlConfigs.length === 0) {
|
|
throw new JacksonError('SAML configuration not found.', 403);
|
|
}
|
|
|
|
// TODO: Support multiple matches
|
|
samlConfig = samlConfigs[0];
|
|
} else {
|
|
samlConfig = await this.configStore.get(client_id);
|
|
}
|
|
} else {
|
|
throw new JacksonError('You need to specify client_id or tenant & product', 403);
|
|
}
|
|
|
|
if (!samlConfig) {
|
|
throw new JacksonError('SAML configuration not found.', 403);
|
|
}
|
|
|
|
if (!allowed.redirect(redirect_uri, samlConfig.redirectUrl)) {
|
|
throw new JacksonError('Redirect URL is not allowed.', 403);
|
|
}
|
|
|
|
const samlReq = saml.request({
|
|
entityID: this.opts.samlAudience!,
|
|
callbackUrl: this.opts.externalUrl + this.opts.samlPath,
|
|
signingKey: samlConfig.certs.privateKey,
|
|
});
|
|
|
|
const sessionId = crypto.randomBytes(16).toString('hex');
|
|
|
|
await this.sessionStore.put(sessionId, {
|
|
id: samlReq.id,
|
|
redirect_uri,
|
|
response_type,
|
|
state,
|
|
code_challenge,
|
|
code_challenge_method,
|
|
});
|
|
|
|
const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
|
|
RelayState: relayStatePrefix + sessionId,
|
|
SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
|
|
});
|
|
|
|
return { redirect_url: redirectUrl };
|
|
}
|
|
|
|
public async samlResponse(body: SAMLResponsePayload): Promise<{ redirect_url: string }> {
|
|
const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
|
|
|
|
let RelayState = body.RelayState || '';
|
|
|
|
if (!this.opts.idpEnabled && !RelayState.startsWith(relayStatePrefix)) {
|
|
// IDP is disabled so block the request
|
|
|
|
throw new JacksonError(
|
|
'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
|
|
403
|
|
);
|
|
}
|
|
|
|
if (!RelayState.startsWith(relayStatePrefix)) {
|
|
RelayState = '';
|
|
}
|
|
|
|
RelayState = RelayState.replace(relayStatePrefix, '');
|
|
|
|
const rawResponse = Buffer.from(SAMLResponse, 'base64').toString();
|
|
|
|
const parsedResp = await saml.parseAsync(rawResponse);
|
|
|
|
const samlConfigs = await this.configStore.getByIndex({
|
|
name: IndexNames.EntityID,
|
|
value: parsedResp?.issuer,
|
|
});
|
|
|
|
if (!samlConfigs || samlConfigs.length === 0) {
|
|
throw new JacksonError('SAML configuration not found.', 403);
|
|
}
|
|
|
|
// TODO: Support multiple matches
|
|
const samlConfig = samlConfigs[0];
|
|
|
|
let session;
|
|
|
|
if (RelayState !== '') {
|
|
session = await this.sessionStore.get(RelayState);
|
|
if (!session) {
|
|
throw new JacksonError('Unable to validate state from the origin request.', 403);
|
|
}
|
|
}
|
|
|
|
const validateOpts: Record<string, string> = {
|
|
thumbprint: samlConfig.idpMetadata.thumbprint,
|
|
audience: this.opts.samlAudience!,
|
|
};
|
|
|
|
if (session && session.id) {
|
|
validateOpts.inResponseTo = session.id;
|
|
}
|
|
|
|
const profile = await saml.validateAsync(rawResponse, validateOpts);
|
|
|
|
// store details against a code
|
|
const code = crypto.randomBytes(20).toString('hex');
|
|
|
|
const codeVal: Record<string, unknown> = {
|
|
profile,
|
|
clientID: samlConfig.clientID,
|
|
clientSecret: samlConfig.clientSecret,
|
|
};
|
|
|
|
if (session) {
|
|
codeVal.session = session;
|
|
}
|
|
|
|
await this.codeStore.put(code, codeVal);
|
|
|
|
if (session && session.redirect_uri && !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)) {
|
|
throw new JacksonError('Redirect URL is not allowed.', 403);
|
|
}
|
|
|
|
const params: Record<string, string> = {
|
|
code,
|
|
};
|
|
|
|
if (session && session.state) {
|
|
params.state = session.state;
|
|
}
|
|
|
|
const redirectUrl = redirect.success(
|
|
(session && session.redirect_uri) || samlConfig.defaultRedirectUrl,
|
|
params
|
|
);
|
|
|
|
return { redirect_url: redirectUrl };
|
|
}
|
|
|
|
public async token(body: OAuthTokenReq): Promise<OAuthTokenRes> {
|
|
const { client_id, client_secret, code_verifier, code, grant_type = 'authorization_code' } = body;
|
|
|
|
if (grant_type !== 'authorization_code') {
|
|
throw new JacksonError('Unsupported grant_type', 400);
|
|
}
|
|
|
|
if (!code) {
|
|
throw new JacksonError('Please specify code', 400);
|
|
}
|
|
|
|
const codeVal = await this.codeStore.get(code);
|
|
if (!codeVal || !codeVal.profile) {
|
|
throw new JacksonError('Invalid code', 403);
|
|
}
|
|
|
|
if (client_id && client_secret) {
|
|
// check if we have an encoded client_id
|
|
if (client_id !== 'dummy' && client_secret !== 'dummy') {
|
|
const sp = getEncodedClientId(client_id);
|
|
if (!sp) {
|
|
// OAuth flow
|
|
if (client_id !== codeVal.clientID || client_secret !== codeVal.clientSecret) {
|
|
throw new JacksonError('Invalid client_id or client_secret', 401);
|
|
}
|
|
}
|
|
}
|
|
} else if (code_verifier) {
|
|
// PKCE flow
|
|
let cv = code_verifier;
|
|
if (codeVal.session.code_challenge_method.toLowerCase() === 's256') {
|
|
cv = codeVerifier.encode(code_verifier);
|
|
}
|
|
|
|
if (codeVal.session.code_challenge !== cv) {
|
|
throw new JacksonError('Invalid code_verifier', 401);
|
|
}
|
|
} else if (codeVal && codeVal.session) {
|
|
throw new JacksonError('Please specify client_secret or code_verifier', 401);
|
|
}
|
|
|
|
// store details against a token
|
|
const token = crypto.randomBytes(20).toString('hex');
|
|
|
|
await this.tokenStore.put(token, codeVal.profile);
|
|
|
|
return {
|
|
access_token: token,
|
|
token_type: 'bearer',
|
|
expires_in: this.opts.db.ttl!,
|
|
};
|
|
}
|
|
|
|
public async userInfo(token: string): Promise<Profile> {
|
|
const { claims } = await this.tokenStore.get(token);
|
|
|
|
return claims;
|
|
}
|
|
}
|