2021-12-24 10:42:04 +00:00
import crypto from 'crypto' ;
2022-02-15 23:34:12 +00:00
import { promisify } from 'util' ;
import { deflateRaw } from 'zlib' ;
2022-09-30 10:37:21 +00:00
import { Client , errors , generators , Issuer , TokenSet } from 'openid-client' ;
2022-07-23 17:04:55 +00:00
import * as jose from 'jose' ;
2022-01-19 00:13:18 +00:00
import * as dbutils from '../db/utils' ;
2022-02-15 23:34:12 +00:00
import * as metrics from '../opentelemetry/metrics' ;
2022-04-26 17:01:55 +00:00
import saml from '@boxyhq/saml20' ;
import claims from '../saml/claims' ;
2022-09-30 10:37:21 +00:00
import type {
OIDCAuthzResponsePayload ,
2021-12-24 10:42:04 +00:00
IOAuthController ,
2021-12-29 17:02:21 +00:00
JacksonOption ,
2022-09-30 10:37:21 +00:00
OAuthReq ,
2021-12-24 10:42:04 +00:00
OAuthTokenReq ,
OAuthTokenRes ,
Profile ,
SAMLResponsePayload ,
2021-12-29 17:02:21 +00:00
Storable ,
2021-12-31 05:56:44 +00:00
} from '../typings' ;
2021-12-24 10:42:04 +00:00
import { JacksonError } from './error' ;
import * as allowed from './oauth/allowed' ;
import * as codeVerifier from './oauth/code-verifier' ;
import * as redirect from './oauth/redirect' ;
2022-07-23 17:04:55 +00:00
import {
relayStatePrefix ,
IndexNames ,
OAuthErrorResponse ,
getErrorMessage ,
loadJWSPrivateKey ,
isJWSKeyPairLoaded ,
} from './utils' ;
2022-09-21 17:21:11 +00:00
import x509 from '../saml/x509' ;
2022-01-20 21:05:23 +00:00
const deflateRawAsync = promisify ( deflateRaw ) ;
2021-11-06 01:14:16 +00:00
2022-09-30 10:37:21 +00:00
const validateSAMLResponse = async ( rawResponse : string , validateOpts ) = > {
2022-05-31 20:37:16 +00:00
const profile = await saml . validate ( rawResponse , validateOpts ) ;
2022-04-26 17:01:55 +00:00
if ( profile && profile . claims ) {
// we map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
profile . claims = claims . map ( profile . claims ) ;
// some providers don't return the id in the assertion, we set it to a sha256 hash of the email
if ( ! profile . claims . id && profile . claims . email ) {
profile . claims . id = crypto . createHash ( 'sha256' ) . update ( profile . claims . email ) . digest ( 'hex' ) ;
}
}
return profile ;
} ;
2021-11-06 01:14:16 +00:00
2022-05-17 10:13:29 +00:00
function getEncodedTenantProduct ( param : string ) : { tenant : string | null ; product : string | null } | null {
2021-11-06 01:14:16 +00:00
try {
2022-05-17 10:13:29 +00:00
const sp = new URLSearchParams ( param ) ;
2021-11-20 21:35:40 +00:00
const tenant = sp . get ( 'tenant' ) ;
const product = sp . get ( 'product' ) ;
if ( tenant && product ) {
return {
tenant : sp.get ( 'tenant' ) ,
product : sp.get ( 'product' ) ,
} ;
}
return null ;
2021-11-06 01:14:16 +00:00
} catch ( err ) {
return null ;
}
}
2022-07-23 17:04:55 +00:00
function getScopeValues ( scope? : string ) : string [ ] {
return typeof scope === 'string' ? scope . split ( ' ' ) . filter ( ( s ) = > s . length > 0 ) : [ ] ;
}
2021-12-24 10:42:04 +00:00
export class OAuthController implements IOAuthController {
2022-09-30 10:37:21 +00:00
private connectionStore : Storable ;
2021-12-29 17:02:21 +00:00
private sessionStore : Storable ;
private codeStore : Storable ;
private tokenStore : Storable ;
private opts : JacksonOption ;
2021-12-24 10:42:04 +00:00
2022-09-30 10:37:21 +00:00
constructor ( { connectionStore , sessionStore , codeStore , tokenStore , opts } ) {
this . connectionStore = connectionStore ;
2021-12-24 10:42:04 +00:00
this . sessionStore = sessionStore ;
this . codeStore = codeStore ;
this . tokenStore = tokenStore ;
this . opts = opts ;
2021-11-06 01:14:16 +00:00
}
2022-09-30 10:37:21 +00:00
private resolveMultipleConnectionMatches (
connections ,
2022-04-29 15:51:03 +00:00
idp_hint ,
originalParams ,
isIdpFlow = false
2022-09-30 10:37:21 +00:00
) : { resolvedConnection? : unknown ; redirect_url? : string ; app_select_form? : string } {
if ( connections . length > 1 ) {
2022-04-29 15:51:03 +00:00
if ( idp_hint ) {
2022-09-30 10:37:21 +00:00
return { resolvedConnection : connections.find ( ( { clientID } ) = > clientID === idp_hint ) } ;
2022-04-29 15:51:03 +00:00
} else if ( this . opts . idpDiscoveryPath ) {
if ( ! isIdpFlow ) {
// redirect to IdP selection page
2022-09-30 10:37:21 +00:00
const idpList = connections . map ( ( { idpMetadata , oidcProvider , clientID } ) = >
2022-04-29 15:51:03 +00:00
JSON . stringify ( {
2022-09-30 10:37:21 +00:00
provider : idpMetadata?.provider ? ? oidcProvider ? . provider ,
2022-04-29 15:51:03 +00:00
clientID ,
2022-09-30 10:37:21 +00:00
connectionIsSAML : idpMetadata && typeof idpMetadata === 'object' ,
connectionIsOIDC : oidcProvider && typeof oidcProvider === 'object' ,
2022-04-29 15:51:03 +00:00
} )
) ;
return {
redirect_url : redirect.success ( this . opts . externalUrl + this . opts . idpDiscoveryPath , {
. . . originalParams ,
idp : idpList ,
} ) ,
} ;
} else {
2022-09-30 10:37:21 +00:00
// Relevant to IdP initiated SAML flow
const appList = connections . map ( ( { product , name , description , clientID } ) = > ( {
2022-04-29 15:51:03 +00:00
product ,
name ,
description ,
clientID ,
} ) ) ;
return {
app_select_form : saml.createPostForm ( this . opts . idpDiscoveryPath , [
{
name : 'SAMLResponse' ,
value : originalParams.SAMLResponse ,
} ,
{
name : 'app' ,
value : encodeURIComponent ( JSON . stringify ( appList ) ) ,
} ,
] ) ,
} ;
}
}
}
return { } ;
}
2022-09-30 10:37:21 +00:00
public async authorize ( body : OAuthReq ) : Promise < { redirect_url? : string ; authorize_form? : string } > {
2021-12-24 10:42:04 +00:00
const {
response_type = 'code' ,
client_id ,
redirect_uri ,
state ,
2022-05-17 10:13:29 +00:00
scope ,
2022-07-23 17:04:55 +00:00
nonce ,
2021-12-24 10:42:04 +00:00
code_challenge ,
code_challenge_method = '' ,
2022-04-29 15:51:03 +00:00
idp_hint ,
2022-09-27 16:49:27 +00:00
prompt ,
2021-12-24 10:42:04 +00:00
} = body ;
2022-09-30 10:37:21 +00:00
const tenant = 'tenant' in body ? body.tenant : undefined ;
const product = 'product' in body ? body.product : undefined ;
const access_type = 'access_type' in body ? body.access_type : undefined ;
const resource = 'resource' in body ? body.resource : undefined ;
2022-04-05 20:28:12 +00:00
let requestedTenant = tenant ;
let requestedProduct = product ;
2022-02-15 23:34:12 +00:00
metrics . increment ( 'oauthAuthorize' ) ;
2021-12-24 10:42:04 +00:00
if ( ! redirect_uri ) {
throw new JacksonError ( 'Please specify a redirect URL.' , 400 ) ;
}
2021-12-08 15:52:34 +00:00
2022-09-30 10:37:21 +00:00
let connection ;
2022-07-23 17:04:55 +00:00
const requestedScopes = getScopeValues ( scope ) ;
const requestedOIDCFlow = requestedScopes . includes ( 'openid' ) ;
2021-12-24 10:42:04 +00:00
if ( tenant && product ) {
2022-09-30 10:37:21 +00:00
const connections = await this . connectionStore . getByIndex ( {
2021-12-23 11:33:26 +00:00
name : IndexNames.TenantProduct ,
2021-12-24 10:42:04 +00:00
value : dbutils.keyFromParts ( tenant , product ) ,
2021-11-06 01:14:16 +00:00
} ) ;
2022-09-30 10:37:21 +00:00
if ( ! connections || connections . length === 0 ) {
throw new JacksonError ( 'IdP connection not found.' , 403 ) ;
2021-11-06 01:14:16 +00:00
}
2022-09-30 10:37:21 +00:00
connection = connections [ 0 ] ;
2022-04-29 15:51:03 +00:00
// Support multiple matches
2022-09-30 10:37:21 +00:00
const { resolvedConnection , redirect_url } = this . resolveMultipleConnectionMatches (
connections ,
idp_hint ,
{
response_type ,
client_id ,
redirect_uri ,
state ,
tenant ,
product ,
access_type ,
resource ,
scope ,
nonce ,
code_challenge ,
code_challenge_method ,
}
) ;
2022-04-29 15:51:03 +00:00
if ( redirect_url ) {
return { redirect_url } ;
}
2022-09-30 10:37:21 +00:00
if ( resolvedConnection ) {
connection = resolvedConnection ;
2022-04-29 15:51:03 +00:00
}
2022-05-17 10:13:29 +00:00
} else if ( client_id && client_id !== '' && client_id !== 'undefined' && client_id !== 'null' ) {
2022-09-30 10:37:21 +00:00
// if tenant and product are encoded in the client_id then we parse it and check for the relevant connection(s)
2022-05-05 18:00:39 +00:00
let sp = getEncodedTenantProduct ( client_id ) ;
if ( ! sp && access_type ) {
sp = getEncodedTenantProduct ( access_type ) ;
}
2022-07-27 20:53:37 +00:00
if ( ! sp && resource ) {
sp = getEncodedTenantProduct ( resource ) ;
}
2022-07-23 17:04:55 +00:00
if ( ! sp && requestedScopes ) {
const encodedParams = requestedScopes . find ( ( scope ) = > scope . includes ( '=' ) && scope . includes ( '&' ) ) ; // for now assume only one encoded param i.e. for tenant/product
if ( encodedParams ) {
sp = getEncodedTenantProduct ( encodedParams ) ;
}
2022-05-17 10:13:29 +00:00
}
2022-04-29 15:51:03 +00:00
if ( sp && sp . tenant && sp . product ) {
2022-04-05 20:28:12 +00:00
requestedTenant = sp . tenant ;
2022-04-29 15:51:03 +00:00
requestedProduct = sp . product ;
2022-04-05 20:28:12 +00:00
2022-09-30 10:37:21 +00:00
const connections = await this . connectionStore . getByIndex ( {
2021-12-24 10:42:04 +00:00
name : IndexNames.TenantProduct ,
2022-04-29 15:51:03 +00:00
value : dbutils.keyFromParts ( sp . tenant , sp . product ) ,
2021-12-24 10:42:04 +00:00
} ) ;
2022-09-30 10:37:21 +00:00
if ( ! connections || connections . length === 0 ) {
throw new JacksonError ( 'IdP connection not found.' , 403 ) ;
2021-12-24 10:42:04 +00:00
}
2022-09-30 10:37:21 +00:00
connection = connections [ 0 ] ;
2022-04-29 15:51:03 +00:00
// Support multiple matches
2022-09-30 10:37:21 +00:00
const { resolvedConnection , redirect_url } = this . resolveMultipleConnectionMatches (
connections ,
2022-04-29 15:51:03 +00:00
idp_hint ,
{
response_type ,
client_id ,
redirect_uri ,
state ,
tenant ,
product ,
2022-08-23 08:29:36 +00:00
access_type ,
resource ,
scope ,
nonce ,
2022-04-29 15:51:03 +00:00
code_challenge ,
code_challenge_method ,
}
) ;
if ( redirect_url ) {
return { redirect_url } ;
}
2022-09-30 10:37:21 +00:00
if ( resolvedConnection ) {
connection = resolvedConnection ;
2022-04-29 15:51:03 +00:00
}
2021-12-24 10:42:04 +00:00
} else {
2022-09-30 10:37:21 +00:00
connection = await this . connectionStore . get ( client_id ) ;
if ( connection ) {
requestedTenant = connection . tenant ;
requestedProduct = connection . product ;
2022-05-10 11:17:57 +00:00
}
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
} else {
2022-01-05 12:09:51 +00:00
throw new JacksonError ( 'You need to specify client_id or tenant & product' , 403 ) ;
2021-11-06 01:14:16 +00:00
}
2022-09-30 10:37:21 +00:00
if ( ! connection ) {
throw new JacksonError ( 'IdP connection not found.' , 403 ) ;
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
2022-09-30 10:37:21 +00:00
if ( ! allowed . redirect ( redirect_uri , connection . redirectUrl ) ) {
2021-12-24 10:42:04 +00:00
throw new JacksonError ( 'Redirect URL is not allowed.' , 403 ) ;
}
2021-11-06 01:14:16 +00:00
2022-07-23 17:04:55 +00:00
if (
requestedOIDCFlow &&
2022-10-11 15:02:18 +00:00
( ! this . opts . openid ? . jwtSigningKeys || ! isJWSKeyPairLoaded ( this . opts . openid . jwtSigningKeys ) )
2022-07-23 17:04:55 +00:00
) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description :
'OAuth server not configured correctly for openid flow, check if JWT signing keys are loaded' ,
redirect_uri ,
} ) ,
} ;
}
2022-05-16 11:46:30 +00:00
if ( ! state ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'invalid_request' ,
error_description : 'Please specify a state to safeguard against XSRF attacks' ,
redirect_uri ,
} ) ,
} ;
}
if ( response_type !== 'code' ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'unsupported_response_type' ,
error_description : 'Only Authorization Code grant is supported' ,
redirect_uri ,
2022-07-06 03:12:43 +00:00
state ,
2022-05-16 11:46:30 +00:00
} ) ,
} ;
}
2022-09-30 10:37:21 +00:00
// Connection retrieved: Handover to IdP starts here
2022-02-17 19:02:03 +00:00
let ssoUrl ;
let post = false ;
2022-09-30 10:37:21 +00:00
const connectionIsSAML = connection . idpMetadata && typeof connection . idpMetadata === 'object' ;
const connectionIsOIDC = connection . oidcProvider && typeof connection . oidcProvider === 'object' ;
// Init sessionId
const sessionId = crypto . randomBytes ( 16 ) . toString ( 'hex' ) ;
const relayState = relayStatePrefix + sessionId ;
// SAML connection: SAML request will be constructed here
let samlReq ;
if ( connectionIsSAML ) {
const { sso } = connection . idpMetadata ;
if ( 'redirectUrl' in sso ) {
// HTTP Redirect binding
ssoUrl = sso . redirectUrl ;
} else if ( 'postUrl' in sso ) {
// HTTP-POST binding
ssoUrl = sso . postUrl ;
post = true ;
} else {
return {
redirect_url : OAuthErrorResponse ( {
error : 'invalid_request' ,
error_description : 'SAML binding could not be retrieved' ,
redirect_uri ,
state ,
} ) ,
} ;
}
2022-02-17 19:02:03 +00:00
2022-09-30 10:37:21 +00:00
try {
const { validTo } = new crypto . X509Certificate ( connection . certs . publicKey ) ;
const isValidExpiry = validTo != 'Bad time value' && new Date ( validTo ) > new Date ( ) ;
if ( ! isValidExpiry ) {
const certs = await x509 . generate ( ) ;
connection . certs = certs ;
if ( certs ) {
await this . connectionStore . put (
connection . clientID ,
connection ,
{
// secondary index on entityID
name : IndexNames.EntityID ,
value : connection.idpMetadata.entityID ,
} ,
{
// secondary index on tenant + product
name : IndexNames.TenantProduct ,
value : dbutils.keyFromParts ( connection . tenant , connection . product ) ,
}
) ;
} else {
throw new Error ( 'Error generating x509 certs' ) ;
}
}
// We will get undefined or Space delimited, case sensitive list of ASCII string values in prompt
// If login is one of the value in prompt we want to enable forceAuthn
// Else use the saml connection forceAuthn value
const promptOptions = prompt ? prompt . split ( ' ' ) . filter ( ( p ) = > p === 'login' ) : [ ] ;
samlReq = saml . request ( {
ssoUrl ,
entityID : this.opts.samlAudience ! ,
callbackUrl : this.opts.externalUrl + this . opts . samlPath ,
signingKey : connection.certs.privateKey ,
publicKey : connection.certs.publicKey ,
forceAuthn : promptOptions.length > 0 ? true : ! ! connection . forceAuthn ,
} ) ;
} catch ( err : unknown ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : getErrorMessage ( err ) ,
redirect_uri ,
state ,
} ) ,
} ;
}
2022-02-17 19:02:03 +00:00
}
2022-09-30 10:37:21 +00:00
// OIDC Connection: Issuer discovery, openid-client init and extraction of authorization endpoint happens here
let oidcCodeVerifier : string | undefined ;
if ( connectionIsOIDC ) {
2022-10-11 15:02:18 +00:00
if ( ! this . opts . oidcPath ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : 'OpenID response handler path (oidcPath) is not set' ,
redirect_uri ,
state ,
} ) ,
} ;
}
2022-09-30 10:37:21 +00:00
const { discoveryUrl , clientId , clientSecret } = connection . oidcProvider ;
try {
const oidcIssuer = await Issuer . discover ( discoveryUrl ) ;
const oidcClient = new oidcIssuer . Client ( {
client_id : clientId ,
client_secret : clientSecret ,
redirect_uris : [ this . opts . externalUrl + this . opts . oidcPath ] ,
response_types : [ 'code' ] ,
} ) ;
oidcCodeVerifier = generators . codeVerifier ( ) ;
const code_challenge = generators . codeChallenge ( oidcCodeVerifier ) ;
ssoUrl = oidcClient . authorizationUrl ( {
scope : [ . . . requestedScopes , 'openid' , 'email' , 'profile' ]
. filter ( ( value , index , self ) = > self . indexOf ( value ) === index ) // filter out duplicates
. join ( ' ' ) ,
code_challenge ,
code_challenge_method : 'S256' ,
state : relayState ,
} ) ;
} catch ( err : unknown ) {
if ( err ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : ( err as errors . OPError ) ? . error || getErrorMessage ( err ) ,
redirect_uri ,
state ,
} ) ,
} ;
2022-09-21 17:21:11 +00:00
}
}
2022-09-30 10:37:21 +00:00
}
// Session persistence happens here
try {
2022-08-12 11:50:05 +00:00
const requested = { client_id , state , redirect_uri } as Record < string , string | boolean | string [ ] > ;
2022-05-16 11:46:30 +00:00
if ( requestedTenant ) {
requested . tenant = requestedTenant ;
}
if ( requestedProduct ) {
requested . product = requestedProduct ;
}
if ( idp_hint ) {
requested . idp_hint = idp_hint ;
}
2022-07-23 17:04:55 +00:00
if ( requestedOIDCFlow ) {
requested . oidc = true ;
if ( nonce ) {
requested . nonce = nonce ;
}
}
if ( requestedScopes ) {
requested . scope = requestedScopes ;
}
2022-03-16 20:50:54 +00:00
2022-09-30 10:37:21 +00:00
const sessionObj = {
2022-05-16 11:46:30 +00:00
redirect_uri ,
response_type ,
state ,
code_challenge ,
code_challenge_method ,
requested ,
2022-09-30 10:37:21 +00:00
} ;
await this . sessionStore . put (
sessionId ,
connectionIsSAML
? {
. . . sessionObj ,
id : samlReq?.id ,
}
: { . . . sessionObj , id : connection.clientID , oidcCodeVerifier }
) ;
// Redirect to IdP
if ( connectionIsSAML ) {
let redirectUrl ;
let authorizeForm ;
if ( ! post ) {
// HTTP Redirect binding
redirectUrl = redirect . success ( ssoUrl , {
RelayState : relayState ,
SAMLRequest : Buffer.from ( await deflateRawAsync ( samlReq . request ) ) . toString ( 'base64' ) ,
} ) ;
} else {
// HTTP POST binding
authorizeForm = saml . createPostForm ( ssoUrl , [
{
name : 'RelayState' ,
value : relayState ,
} ,
{
name : 'SAMLRequest' ,
value : Buffer.from ( samlReq . request ) . toString ( 'base64' ) ,
} ,
] ) ;
}
return {
redirect_url : redirectUrl ,
authorize_form : authorizeForm ,
} ;
} else if ( connectionIsOIDC ) {
return { redirect_url : ssoUrl } ;
2022-05-16 11:46:30 +00:00
} else {
2022-09-30 10:37:21 +00:00
return {
redirect_url : OAuthErrorResponse ( {
error : 'invalid_request' ,
error_description : 'Connection appears to be misconfigured' ,
redirect_uri ,
state ,
} ) ,
} ;
2022-05-16 11:46:30 +00:00
}
} catch ( err : unknown ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : getErrorMessage ( err ) ,
redirect_uri ,
2022-07-06 03:12:43 +00:00
state ,
2022-05-16 11:46:30 +00:00
} ) ,
} ;
2022-02-17 19:02:03 +00:00
}
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
2022-04-29 15:51:03 +00:00
public async samlResponse (
body : SAMLResponsePayload
) : Promise < { redirect_url? : string ; app_select_form? : string } > {
const { SAMLResponse , idp_hint } = body ;
2021-11-06 01:14:16 +00:00
2022-02-12 22:39:11 +00:00
let RelayState = body . RelayState || '' ; // RelayState will contain the sessionId from earlier quasi-oauth flow
2021-11-06 01:14:16 +00:00
2022-04-29 15:51:03 +00:00
const isIdPFlow = ! RelayState . startsWith ( relayStatePrefix ) ;
if ( ! this . opts . idpEnabled && isIdPFlow ) {
2021-12-24 10:42:04 +00:00
// IDP is disabled so block the request
2021-11-28 20:16:20 +00:00
2021-12-24 10:42:04 +00:00
throw new JacksonError (
'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.' ,
403
) ;
}
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
RelayState = RelayState . replace ( relayStatePrefix , '' ) ;
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
const rawResponse = Buffer . from ( SAMLResponse , 'base64' ) . toString ( ) ;
2021-11-06 01:14:16 +00:00
2022-05-31 20:37:16 +00:00
const issuer = saml . parseIssuer ( rawResponse ) ;
if ( ! issuer ) {
throw new JacksonError ( 'Issuer not found.' , 403 ) ;
}
2022-09-30 10:37:21 +00:00
const samlConnections = await this . connectionStore . getByIndex ( {
2021-12-24 10:42:04 +00:00
name : IndexNames.EntityID ,
2022-05-31 20:37:16 +00:00
value : issuer ,
2021-12-24 10:42:04 +00:00
} ) ;
2021-11-06 01:14:16 +00:00
2022-09-30 10:37:21 +00:00
if ( ! samlConnections || samlConnections . length === 0 ) {
throw new JacksonError ( 'SAML connection not found.' , 403 ) ;
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
2022-09-30 10:37:21 +00:00
let samlConnection = samlConnections [ 0 ] ;
2022-04-29 15:51:03 +00:00
if ( isIdPFlow ) {
RelayState = '' ;
2022-09-30 10:37:21 +00:00
const { resolvedConnection , app_select_form } = this . resolveMultipleConnectionMatches (
samlConnections ,
2022-04-29 15:51:03 +00:00
idp_hint ,
{ SAMLResponse } ,
true
) ;
if ( app_select_form ) {
return { app_select_form } ;
}
2022-09-30 10:37:21 +00:00
if ( resolvedConnection ) {
samlConnection = resolvedConnection ;
2022-04-29 15:51:03 +00:00
}
}
2021-12-24 10:42:04 +00:00
let session ;
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
if ( RelayState !== '' ) {
session = await this . sessionStore . get ( RelayState ) ;
if ( ! session ) {
2022-01-05 12:09:51 +00:00
throw new JacksonError ( 'Unable to validate state from the origin request.' , 403 ) ;
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
}
2022-04-29 15:51:03 +00:00
if ( ! isIdPFlow ) {
// Resolve if there are multiple matches for SP login. TODO: Support multiple matches for IdP login
2022-09-30 10:37:21 +00:00
samlConnection =
samlConnections . length === 1
? samlConnections [ 0 ]
: samlConnections . filter ( ( c ) = > {
2022-04-29 15:51:03 +00:00
return (
c . clientID === session ? . requested ? . client_id ||
( c . tenant === session ? . requested ? . tenant && c . product === session ? . requested ? . product )
) ;
} ) [ 0 ] ;
}
2022-04-05 20:28:12 +00:00
2022-09-30 10:37:21 +00:00
if ( ! samlConnection ) {
throw new JacksonError ( 'SAML connection not found.' , 403 ) ;
2022-04-05 20:28:12 +00:00
}
2021-12-30 08:30:15 +00:00
const validateOpts : Record < string , string > = {
2022-09-30 10:37:21 +00:00
thumbprint : samlConnection.idpMetadata.thumbprint ,
2021-12-31 17:02:01 +00:00
audience : this.opts.samlAudience ! ,
2022-09-30 10:37:21 +00:00
privateKey : samlConnection.certs.privateKey ,
2021-12-24 10:42:04 +00:00
} ;
2021-11-06 01:14:16 +00:00
2022-09-30 10:37:21 +00:00
if (
session &&
session . redirect_uri &&
! allowed . redirect ( session . redirect_uri , samlConnection . redirectUrl )
) {
2022-05-16 11:46:30 +00:00
throw new JacksonError ( 'Redirect URL is not allowed.' , 403 ) ;
}
2021-12-24 10:42:04 +00:00
if ( session && session . id ) {
validateOpts . inResponseTo = session . id ;
}
2021-11-06 01:14:16 +00:00
2022-05-16 11:46:30 +00:00
let profile ;
2022-09-30 10:37:21 +00:00
const redirect_uri = ( session && session . redirect_uri ) || samlConnection . defaultRedirectUrl ;
2022-05-16 11:46:30 +00:00
try {
2022-09-30 10:37:21 +00:00
profile = await validateSAMLResponse ( rawResponse , validateOpts ) ;
2022-05-16 11:46:30 +00:00
} catch ( err : unknown ) {
// return error to redirect_uri
return {
redirect_url : OAuthErrorResponse ( {
error : 'access_denied' ,
error_description : getErrorMessage ( err ) ,
redirect_uri ,
2022-07-06 03:12:43 +00:00
state : session?.requested?.state ,
2022-05-16 11:46:30 +00:00
} ) ,
} ;
}
2021-12-24 10:42:04 +00:00
// store details against a code
const code = crypto . randomBytes ( 20 ) . toString ( 'hex' ) ;
2021-11-06 01:14:16 +00:00
2021-12-30 08:30:15 +00:00
const codeVal : Record < string , unknown > = {
2021-12-24 10:42:04 +00:00
profile ,
2022-09-30 10:37:21 +00:00
clientID : samlConnection.clientID ,
clientSecret : samlConnection.clientSecret ,
2022-03-20 02:41:29 +00:00
requested : session?.requested ,
2021-12-24 10:42:04 +00:00
} ;
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
if ( session ) {
codeVal . session = session ;
}
2021-11-06 01:14:16 +00:00
2022-05-16 11:46:30 +00:00
try {
await this . codeStore . put ( code , codeVal ) ;
} catch ( err : unknown ) {
// return error to redirect_uri
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : getErrorMessage ( err ) ,
redirect_uri ,
2022-07-06 03:12:43 +00:00
state : session?.requested?.state ,
2022-05-16 11:46:30 +00:00
} ) ,
} ;
2021-12-24 10:42:04 +00:00
}
2021-11-06 01:14:16 +00:00
2021-12-30 08:30:15 +00:00
const params : Record < string , string > = {
2021-12-24 10:42:04 +00:00
code ,
} ;
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
if ( session && session . state ) {
params . state = session . state ;
}
2021-11-06 01:14:16 +00:00
2022-05-16 11:46:30 +00:00
const redirectUrl = redirect . success ( redirect_uri , params ) ;
2021-11-06 01:14:16 +00:00
2022-03-10 22:38:06 +00:00
// delete the session
try {
await this . sessionStore . delete ( RelayState ) ;
} catch ( _err ) {
// ignore error
}
2021-12-24 10:42:04 +00:00
return { redirect_url : redirectUrl } ;
2021-11-06 01:14:16 +00:00
}
2022-09-30 10:37:21 +00:00
private async extractOIDCUserProfile ( tokenSet : TokenSet , oidcClient : Client ) {
const profile : { claims : Partial < Profile & { raw : Record < string , unknown > } > } = { claims : { } } ;
const idTokenClaims = tokenSet . claims ( ) ;
const userinfo = await oidcClient . userinfo ( tokenSet ) ;
profile . claims . id = idTokenClaims . sub ;
profile . claims . email = idTokenClaims . email ? ? userinfo . email ;
profile . claims . firstName = idTokenClaims . given_name ? ? userinfo . given_name ;
profile . claims . lastName = idTokenClaims . family_name ? ? userinfo . family_name ;
profile . claims . raw = userinfo ;
return profile ;
}
public async oidcAuthzResponse ( body : OIDCAuthzResponsePayload ) : Promise < { redirect_url? : string } > {
const { code : opCode , state , error , error_description } = body ;
let RelayState = state || '' ;
if ( ! RelayState ) {
throw new JacksonError ( 'State from original request is missing.' , 403 ) ;
}
RelayState = RelayState . replace ( relayStatePrefix , '' ) ;
const session = await this . sessionStore . get ( RelayState ) ;
if ( ! session ) {
throw new JacksonError ( 'Unable to validate state from the original request.' , 403 ) ;
}
const oidcConnection = await this . connectionStore . get ( session . id ) ;
if ( session . redirect_uri && ! allowed . redirect ( session . redirect_uri , oidcConnection . redirectUrl ) ) {
throw new JacksonError ( 'Redirect URL is not allowed.' , 403 ) ;
}
const redirect_uri = ( session && session . redirect_uri ) || oidcConnection . defaultRedirectUrl ;
if ( error ) {
return {
redirect_url : OAuthErrorResponse ( {
error ,
error_description : error_description ? ? 'Authorization failure at OIDC Provider' ,
redirect_uri ,
state : session.state ,
} ) ,
} ;
}
if ( ! opCode ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : 'Authorization code could not be retrieved from OIDC Provider' ,
redirect_uri ,
state : session.state ,
} ) ,
} ;
}
// Reconstruct the oidcClient
const { discoveryUrl , clientId , clientSecret } = oidcConnection . oidcProvider ;
let profile ;
try {
const oidcIssuer = await Issuer . discover ( discoveryUrl ) ;
const oidcClient = new oidcIssuer . Client ( {
client_id : clientId ,
client_secret : clientSecret ,
redirect_uris : [ this . opts . externalUrl + this . opts . oidcPath ] ,
response_types : [ 'code' ] ,
} ) ;
const tokenSet = await oidcClient . callback (
this . opts . externalUrl + this . opts . oidcPath ,
{
code : opCode ,
} ,
{ code_verifier : session.oidcCodeVerifier }
) ;
profile = await this . extractOIDCUserProfile ( tokenSet , oidcClient ) ;
} catch ( err : unknown ) {
if ( err ) {
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : ( err as errors . OPError ) ? . error || getErrorMessage ( err ) ,
redirect_uri ,
state : session.state ,
} ) ,
} ;
}
}
// store details against a code
const code = crypto . randomBytes ( 20 ) . toString ( 'hex' ) ;
const codeVal : Record < string , unknown > = {
profile ,
clientID : oidcConnection.clientID ,
clientSecret : oidcConnection.clientSecret ,
requested : session?.requested ,
} ;
if ( session ) {
codeVal . session = session ;
}
try {
await this . codeStore . put ( code , codeVal ) ;
} catch ( err : unknown ) {
// return error to redirect_uri
return {
redirect_url : OAuthErrorResponse ( {
error : 'server_error' ,
error_description : getErrorMessage ( err ) ,
redirect_uri ,
state : session.state ,
} ) ,
} ;
}
const params : Record < string , string > = {
code ,
} ;
if ( session && session . state ) {
params . state = session . state ;
}
const redirectUrl = redirect . success ( redirect_uri , params ) ;
// delete the session
try {
await this . sessionStore . delete ( RelayState ) ;
} catch ( _err ) {
// ignore error
}
return { redirect_url : redirectUrl } ;
}
2022-01-19 00:13:18 +00:00
/ * *
* @swagger
*
* / o a u t h / t o k e n :
* post :
* summary : Code exchange
* operationId : oauth - code - exchange
* tags :
* - OAuth
* consumes :
* - application / x - www - form - urlencoded
* parameters :
* - name : grant_type
* in : formData
* type : string
* description : Grant type should be 'authorization_code'
* default : authorization_code
* required : true
* - name : client_id
* in : formData
* type : string
2022-09-30 10:37:21 +00:00
* description : Use the client_id returned by the SAML connection API
2022-01-19 00:13:18 +00:00
* required : true
* - name : client_secret
* in : formData
* type : string
2022-09-30 10:37:21 +00:00
* description : Use the client_secret returned by the SAML connection API
2022-01-19 00:13:18 +00:00
* required : true
2022-09-30 10:37:21 +00:00
* - name : code_verifier
* in : formData
* type : string
* description : code_verifier against the code_challenge in the authz request ( relevant to PKCE flow )
2022-01-19 00:13:18 +00:00
* - name : redirect_uri
* in : formData
* type : string
* description : Redirect URI
* required : true
* - name : code
* in : formData
* type : string
* description : Code
* required : true
* responses :
* '200' :
* description : Success
* schema :
* type : object
* properties :
* access_token :
* type : string
* token_type :
* type : string
* expires_in :
* type : string
* example :
* access_token : 8958e13053832b5af58fdf2ee83f35f5d013dc74
* token_type : bearer
* expires_in : 300
* /
2021-12-24 10:42:04 +00:00
public async token ( body : OAuthTokenReq ) : Promise < OAuthTokenRes > {
2022-09-30 10:37:21 +00:00
const { code , grant_type = 'authorization_code' , redirect_uri } = body ;
const client_id = 'client_id' in body ? body.client_id : undefined ;
const client_secret = 'client_secret' in body ? body.client_secret : undefined ;
const code_verifier = 'code_verifier' in body ? body.code_verifier : undefined ;
2021-12-24 10:42:04 +00:00
2022-02-15 23:34:12 +00:00
metrics . increment ( 'oauthToken' ) ;
2021-12-24 10:42:04 +00:00
if ( grant_type !== 'authorization_code' ) {
throw new JacksonError ( 'Unsupported grant_type' , 400 ) ;
}
2021-11-06 01:14:16 +00:00
2021-12-24 10:42:04 +00:00
if ( ! code ) {
throw new JacksonError ( 'Please specify code' , 400 ) ;
2021-11-06 01:14:16 +00:00
}
2021-12-24 10:42:04 +00:00
const codeVal = await this . codeStore . get ( code ) ;
if ( ! codeVal || ! codeVal . profile ) {
throw new JacksonError ( 'Invalid code' , 403 ) ;
2021-11-06 01:14:16 +00:00
}
2022-08-15 06:29:51 +00:00
if ( codeVal . requested ? . redirect_uri ) {
2022-08-12 11:50:05 +00:00
if ( redirect_uri !== codeVal . requested . redirect_uri ) {
throw new JacksonError (
` Invalid request: ${ ! redirect_uri ? 'redirect_uri missing' : 'redirect_uri mismatch' } ` ,
400
) ;
}
}
2022-01-08 00:59:48 +00:00
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 ( client_id && client_secret ) {
2021-12-24 10:42:04 +00:00
// check if we have an encoded client_id
2022-02-13 23:44:45 +00:00
if ( client_id !== 'dummy' ) {
2022-05-05 18:00:39 +00:00
const sp = getEncodedTenantProduct ( client_id ) ;
2021-12-24 10:42:04 +00:00
if ( ! sp ) {
// OAuth flow
2022-01-05 12:09:51 +00:00
if ( client_id !== codeVal . clientID || client_secret !== codeVal . clientSecret ) {
2021-12-24 10:42:04 +00:00
throw new JacksonError ( 'Invalid client_id or client_secret' , 401 ) ;
}
2022-02-15 14:09:56 +00:00
} else {
2022-09-27 16:49:57 +00:00
if ( sp . tenant !== codeVal . requested ? . tenant || sp . product !== codeVal . requested ? . product ) {
throw new JacksonError ( 'Invalid tenant or product' , 401 ) ;
}
2022-02-15 14:09:56 +00:00
// encoded client_id, verify client_secret
if ( client_secret !== this . opts . clientSecretVerifier ) {
throw new JacksonError ( 'Invalid client_secret' , 401 ) ;
}
2021-12-24 10:42:04 +00:00
}
2022-03-31 19:22:59 +00:00
} else {
if ( client_secret !== this . opts . clientSecretVerifier && client_secret !== codeVal . clientSecret ) {
throw new JacksonError ( 'Invalid client_secret' , 401 ) ;
}
2021-12-24 10:42:04 +00:00
}
} else if ( codeVal && codeVal . session ) {
2022-01-05 12:09:51 +00:00
throw new JacksonError ( 'Please specify client_secret or code_verifier' , 401 ) ;
2021-11-06 01:14:16 +00:00
}
2021-12-24 10:42:04 +00:00
// store details against a token
const token = crypto . randomBytes ( 20 ) . toString ( 'hex' ) ;
2022-03-16 20:50:54 +00:00
const tokenVal = {
. . . codeVal . profile ,
requested : codeVal.requested ,
} ;
2022-08-15 06:29:51 +00:00
const requestedOIDCFlow = ! ! codeVal . requested ? . oidc ;
const requestHasNonce = ! ! codeVal . requested ? . nonce ;
2022-07-23 17:04:55 +00:00
if ( requestedOIDCFlow ) {
2022-10-11 15:02:18 +00:00
const { jwtSigningKeys , jwsAlg } = this . opts . openid ? ? { } ;
2022-07-23 17:04:55 +00:00
if ( ! jwtSigningKeys || ! isJWSKeyPairLoaded ( jwtSigningKeys ) ) {
throw new JacksonError ( 'JWT signing keys are not loaded' , 500 ) ;
}
let claims : Record < string , string > = requestHasNonce ? { nonce : codeVal.requested.nonce } : { } ;
claims = {
. . . claims ,
id : codeVal.profile.claims.id ,
email : codeVal.profile.claims.email ,
firstName : codeVal.profile.claims.firstName ,
lastName : codeVal.profile.claims.lastName ,
} ;
2022-08-01 12:57:03 +00:00
const signingKey = await loadJWSPrivateKey ( jwtSigningKeys . private , jwsAlg ! ) ;
2022-07-23 17:04:55 +00:00
const id_token = await new jose . SignJWT ( claims )
2022-08-01 12:57:03 +00:00
. setProtectedHeader ( { alg : jwsAlg ! } )
2022-07-23 17:04:55 +00:00
. setIssuedAt ( )
. setIssuer ( this . opts . samlAudience || '' )
. setSubject ( codeVal . profile . claims . id )
. setAudience ( tokenVal . requested . client_id )
. setExpirationTime ( ` ${ this . opts . db . ttl } s ` ) // identity token only really needs to be valid long enough for it to be verified by the client application.
. sign ( signingKey ) ;
tokenVal . id_token = id_token ;
tokenVal . claims . sub = codeVal . profile . claims . id ;
}
2022-03-16 20:50:54 +00:00
await this . tokenStore . put ( token , tokenVal ) ;
2021-12-24 10:42:04 +00:00
2022-03-10 22:38:06 +00:00
// delete the code
try {
await this . codeStore . delete ( code ) ;
} catch ( _err ) {
// ignore error
}
2022-07-23 17:04:55 +00:00
const tokenResponse : OAuthTokenRes = {
2021-12-24 10:42:04 +00:00
access_token : token ,
token_type : 'bearer' ,
2021-12-31 14:31:50 +00:00
expires_in : this.opts.db.ttl ! ,
2021-12-24 10:42:04 +00:00
} ;
2022-07-23 17:04:55 +00:00
if ( requestedOIDCFlow ) {
tokenResponse . id_token = tokenVal . id_token ;
}
return tokenResponse ;
2021-11-06 01:14:16 +00:00
}
2022-01-19 00:13:18 +00:00
/ * *
* @swagger
*
* / o a u t h / u s e r i n f o :
* get :
* summary : Get profile
* operationId : oauth - get - profile
* tags :
* - OAuth
* responses :
* '200' :
* description : Success
* schema :
* type : object
* properties :
* id :
* type : string
* email :
* type : string
* firstName :
* type : string
* lastName :
* type : string
2022-09-30 10:37:21 +00:00
* raw :
* type : object
* requested :
* type : object
2022-01-19 00:13:18 +00:00
* example :
* id : 32b5af58fdf
* email : jackson @coolstartup . com
* firstName : SAML
* lastName : Jackson
2022-09-30 10:37:21 +00:00
* raw : {
*
* }
* requested : {
*
* }
2022-01-19 00:13:18 +00:00
* /
2021-12-24 10:42:04 +00:00
public async userInfo ( token : string ) : Promise < Profile > {
2022-01-20 21:05:23 +00:00
const rsp = await this . tokenStore . get ( token ) ;
2022-02-15 23:34:12 +00:00
metrics . increment ( 'oauthUserInfo' ) ;
2022-01-20 21:05:23 +00:00
if ( ! rsp || ! rsp . claims ) {
throw new JacksonError ( 'Invalid token' , 403 ) ;
}
2021-12-24 10:42:04 +00:00
2022-03-16 20:50:54 +00:00
return {
. . . rsp . claims ,
requested : rsp.requested ,
} ;
2021-12-24 10:42:04 +00:00
}
}