-
Notifications
You must be signed in to change notification settings - Fork 78
Feat/saml auth #8201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Feat/saml auth #8201
Changes from all commits
38faecc
580d153
a04789a
6e5c308
7467328
a805919
0ef729f
309fc2c
6438a31
bbe99f0
84861a6
045e3dc
10c0866
38ae038
44a93fa
6e8a2d9
6df0f67
19c0617
194ca50
e15f1f5
5f1e808
86f6666
bb4f1bc
6d63f92
37298c6
dd76990
ce069e3
9c740c2
5d5a8c5
e01291e
390f364
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -140,4 +140,4 @@ export class OidcStrategy extends PassportStrategy(Strategy as any, 'oidc') { | |
| ) | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { UnauthorizedException } from '@nestjs/common'; | ||
| import _ from 'lodash'; | ||
|
Check warning on line 2 in apps/backend/src/authn/resolve_claim.ts
|
||
|
|
||
| export function getRequiredClaim( | ||
| claims: Record<string, unknown>, | ||
| defaultClaimName: string, | ||
| configuredClaimName?: string, | ||
| ): string { | ||
| const resolvedClaimName = configuredClaimName || defaultClaimName; | ||
| const claimValue = _.get(claims, [resolvedClaimName]) | ||
| ?? _.get(claims, resolvedClaimName); | ||
| const firstClaimValue = Array.isArray(claimValue) | ||
| ? claimValue.find(value => typeof value === 'string' && value.length > 0) | ||
| : claimValue; | ||
|
|
||
| if (typeof firstClaimValue === 'string' && firstClaimValue.length > 0) { | ||
| return firstClaimValue; | ||
| } | ||
|
|
||
| throw new UnauthorizedException(`Missing required claim "${resolvedClaimName}".`); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nonblocking: consider making a helper function that returns all of the SAML envvars that we need to do stuff with SAML, ex. gets the envvar (which we will need to create) that names the attribute that we will use to do 'given_name' / first_name claim equivalent it should also handle default values for stuff that was not provided by the user ex. user provides SAML_IDENTIFIER_FORMAT but not SAML_IDP_ISSUER. is there a default value that we can provide there? otherwise this function should throw an error that explains what happened. we will then call the function within the constructor in order to make it fill out the information and throw the error at the time of construction if all of our needs are not satisfied.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if you wanted to be fancy instead of returning a record<string, string> you could define a type that looks like {SAML_IDENTIFIER_FORMAT: string, blah blah}. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { Injectable } from '@nestjs/common'; | ||
| import { PassportStrategy } from '@nestjs/passport'; | ||
| import type { Profile, SamlScopingConfig } from '@node-saml/passport-saml'; | ||
| import { Strategy } from '@node-saml/passport-saml'; | ||
| import winston from 'winston'; | ||
| import { ConfigService } from '../config/config.service'; | ||
| import { User } from '../users/user.model'; | ||
| import { AuthnService } from './authn.service'; | ||
| import { getRequiredClaim } from './resolve_claim'; | ||
|
|
||
| function getSamlScoping( | ||
| configService: ConfigService, | ||
| ): SamlScopingConfig | undefined { | ||
| const idpList = configService.get('SAML_SCOPING_IDP_LIST'); | ||
| const proxyCount = configService.get('SAML_SCOPING_PROXY_COUNT'); | ||
| const requesterId = configService.get('SAML_SCOPING_REQUESTER_ID'); | ||
|
|
||
| if (!proxyCount && !requesterId && !idpList) { | ||
| return undefined; | ||
| } | ||
|
|
||
| return { | ||
| idpList: idpList | ||
| ? JSON.parse(idpList) | ||
| : undefined, | ||
| proxyCount: proxyCount | ||
| ? Number(proxyCount) | ||
| : undefined, | ||
| requesterId: requesterId?.includes(',') | ||
| ? requesterId.split(',').map(value => value.trim()) | ||
| : requesterId, | ||
| }; | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class SAMLStrategy extends PassportStrategy(Strategy as any, 'saml') { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. try to remove 'as any' cause sometimes the strategy 'shape'/type matches the new passportstrategy type. other times, especially older / less maintained strategies need to use the 'as any' escape mechanism. |
||
| public loggingTimeFormat = 'MMM-DD-YYYY HH:mm:ss Z'; | ||
| private readonly line = '_______________________________________________\n'; | ||
| public logger = winston.createLogger({ | ||
| format: winston.format.combine( | ||
| winston.format.timestamp({ format: this.loggingTimeFormat }), | ||
| winston.format.printf( | ||
| info => | ||
| `${this.line}[${[info.timestamp]}] (Authn Service): ${info.message}`, | ||
| ), | ||
| ), | ||
| transports: [new winston.transports.Console()], | ||
| }); | ||
|
|
||
| constructor( | ||
| private readonly authnService: AuthnService, | ||
| private readonly configService: ConfigService, | ||
| ) { | ||
| const samlAudience = configService.get('SAML_AUDIENCE'); | ||
| super({ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add support for all the other config options for SAML especially make sure that any of the certs / proxies / etc options are defined |
||
| acceptedClockSkewMs: Number(configService.get('SAML_ACCEPTED_CLOCK_SKEW_MS') ?? 0), | ||
| additionalAuthorizeParams: configService.get('SAML_ADDITIONAL_AUTHORIZE_PARAMS') ? JSON.parse(configService.get('SAML_ADDITIONAL_AUTHORIZE_PARAMS')!) : undefined, | ||
| additionalLogoutParams: configService.get('SAML_ADDITIONAL_LOGOUT_PARAMS') ? JSON.parse(configService.get('SAML_ADDITIONAL_LOGOUT_PARAMS')!) : undefined, | ||
| additionalParams: configService.get('SAML_ADDITIONAL_PARAMS') ? JSON.parse(configService.get('SAML_ADDITIONAL_PARAMS')!) : undefined, | ||
| allowCreate: (configService.get('SAML_ALLOW_CREATE') ?? 'true').toLowerCase() === 'true', | ||
| attributeConsumingServiceIndex: configService.get('SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX'), | ||
| audience: | ||
| samlAudience?.toLowerCase() === 'false' | ||
| ? false | ||
| : samlAudience | ||
| || configService.get('SAML_ISSUER'), | ||
| authnContext: configService.get('SAML_AUTHN_CONTEXT') ? configService.get('SAML_AUTHN_CONTEXT')?.split(',').map(value => value.trim()) : undefined, | ||
| authnRequestBinding: configService.get('SAML_AUTHN_REQUEST_BINDING'), | ||
| callbackUrl: | ||
| configService.get('SAML_CALLBACK_URL') | ||
| || `${configService.getExternalUrl()}/authn/saml/callback`, | ||
| decryptionPvk: configService.get('SAML_DECRYPTION_PVK'), | ||
| digestAlgorithm: configService.get('SAML_DIGEST_ALGORITHM'), | ||
| disableRequestAcsUrl: (configService.get('SAML_DISABLE_REQUEST_ACS_URL') ?? '').toLowerCase() === 'true', | ||
| disableRequestedAuthnContext: (configService.get('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT') ?? '').toLowerCase() === 'true', | ||
| entryPoint: configService.get('SAML_ENTRY_POINT') || 'disabled', | ||
| forceAuthn: (configService.get('SAML_FORCE_AUTHN') ?? '').toLowerCase() === 'true', | ||
| identifierFormat: configService.get('SAML_IDENTIFIER_FORMAT'), | ||
| idpCert: configService.get('SAML_IDP_CERT') || 'disabled', | ||
| idpIssuer: configService.get('SAML_IDP_ISSUER'), | ||
| issuer: configService.get('SAML_ISSUER') || 'disabled', | ||
| logoutCallbackUrl: configService.get('SAML_LOGOUT_CALLBACK_URL'), | ||
| logoutUrl: configService.get('SAML_LOGOUT_URL'), | ||
| maxAssertionAgeMs: Number(configService.get('SAML_MAX_ASSERTION_AGE_MS') ?? 0), | ||
| metadataContactPerson: configService.get('SAML_METADATA_CONTACT_PERSON') ? JSON.parse(configService.get('SAML_METADATA_CONTACT_PERSON')!) : undefined, | ||
| metadataOrganization: configService.get('SAML_METADATA_ORGANIZATION') ? JSON.parse(configService.get('SAML_METADATA_ORGANIZATION')!) : undefined, | ||
| passive: (configService.get('SAML_PASSIVE') ?? '').toLowerCase() === 'true', | ||
| privateKey: configService.get('SAML_PRIVATE_KEY'), | ||
| providerName: configService.get('SAML_PROVIDER_NAME'), | ||
| publicCert: configService.get('SAML_PUBLIC_CERT'), | ||
| racComparison: configService.get('SAML_RAC_COMPARISON') as | ||
| | 'better' | ||
| | 'exact' | ||
| | 'maximum' | ||
| | 'minimum' | ||
| | undefined, | ||
| requestIdExpirationPeriodMs: Number(configService.get('SAML_REQUEST_ID_EXPIRATION_PERIOD_MS') ?? 28_800_000), | ||
| samlAuthnRequestExtensions: configService.get('SAML_AUTHN_REQUEST_EXTENSIONS') ? JSON.parse(configService.get('SAML_AUTHN_REQUEST_EXTENSIONS')!) : undefined, | ||
| samlLogoutRequestExtensions: configService.get('SAML_LOGOUT_REQUEST_EXTENSIONS') ? JSON.parse(configService.get('SAML_LOGOUT_REQUEST_EXTENSIONS')!) : undefined, | ||
| scoping: getSamlScoping(configService), | ||
| signatureAlgorithm: configService.get('SAML_SIGNATURE_ALGORITHM') as 'sha1' | 'sha256' | 'sha512' | undefined, | ||
| signMetadata: (configService.get('SAML_SIGN_METADATA') ?? '').toLowerCase() === 'true', | ||
| skipRequestCompression: (configService.get('SAML_SKIP_REQUEST_COMPRESSION') ?? '').toLowerCase() === 'true', | ||
| spNameQualifier: configService.get('SAML_SP_NAME_QUALIFIER'), | ||
| validateInResponseTo: configService.get('SAML_VALIDATE_IN_RESPONSE_TO') as 'always' | 'ifPresent' | 'never' | undefined, | ||
| wantAssertionsSigned: (configService.get('SAML_WANT_ASSERTIONS_SIGNED') ?? 'true').toLowerCase() === 'true', | ||
| wantAuthnResponseSigned: (configService.get('SAML_WANT_AUTHN_RESPONSE_SIGNED') ?? 'true').toLowerCase() === 'true', | ||
| xmlSignatureTransforms: configService.get('SAML_XML_SIGNATURE_TRANSFORMS') ? JSON.parse(configService.get('SAML_XML_SIGNATURE_TRANSFORMS')!) : undefined, | ||
| }); | ||
| } | ||
|
|
||
| async validate(profile: Profile): Promise<User> { | ||
| this.logger.debug('in saml strategy file'); | ||
| this.logger.debug(JSON.stringify(profile, null, 2)); | ||
| return this.authnService.validateOrCreateUser( | ||
| getRequiredClaim( | ||
| profile, | ||
| 'email', | ||
| this.configService.get('SAML_EMAIL_ATTRIBUTE'), | ||
| ), | ||
| getRequiredClaim( | ||
| profile, | ||
| 'firstName', | ||
| this.configService.get('SAML_FIRST_NAME_ATTRIBUTE'), | ||
| ), | ||
| getRequiredClaim( | ||
| profile, | ||
| 'lastName', | ||
| this.configService.get('SAML_LAST_NAME_ATTRIBUTE'), | ||
| ), | ||
| 'saml', | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
make sure to follow along with all the other locations that LDAP does stuff, ex:
https://github.com/search?q=repo%3Amitre%2Fheimdall2+ldap&type=code&p=1