Skip to content
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

feat(auth): Added code flow support for OIDC flow. #1220

Merged
merged 17 commits into from
May 25, 2021
Merged
Show file tree
Hide file tree
Changes from 8 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
8 changes: 8 additions & 0 deletions etc/firebase-admin.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,23 @@ export namespace auth {
export interface MultiFactorUpdateSettings {
enrolledFactors: UpdateMultiFactorInfoRequest[] | null;
}
export interface OAuthResponseType {
code?: boolean;
idToken?: boolean;
}
export interface OIDCAuthProviderConfig extends AuthProviderConfig {
clientId: string;
clientSecret?: string;
issuer: string;
responseType?: OAuthResponseType;
}
export interface OIDCUpdateAuthProviderRequest {
clientId?: string;
clientSecret?: string;
displayName?: string;
enabled?: boolean;
issuer?: string;
responseType?: OAuthResponseType;
}
export interface PhoneIdentifier {
// (undocumented)
Expand Down
83 changes: 83 additions & 0 deletions src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import MultiFactorConfigState = auth.MultiFactorConfigState;
import AuthFactorType = auth.AuthFactorType;
import EmailSignInProviderConfig = auth.EmailSignInProviderConfig;
import OIDCAuthProviderConfig = auth.OIDCAuthProviderConfig;
import OAuthResponseType = auth.OAuthResponseType;
import SAMLAuthProviderConfig = auth.SAMLAuthProviderConfig;

/** A maximum of 10 test phone number / code pairs can be configured. */
Expand Down Expand Up @@ -75,6 +76,8 @@ export interface OIDCConfigServerRequest {
issuer?: string;
displayName?: string;
enabled?: boolean;
clientSecret?: string;
responseType?: OAuthResponseType;
[key: string]: any;
}

Expand All @@ -87,6 +90,8 @@ export interface OIDCConfigServerResponse {
issuer?: string;
displayName?: string;
enabled?: boolean;
clientSecret?: string;
responseType?: OAuthResponseType;
}

/** The server side email configuration request interface. */
Expand Down Expand Up @@ -650,6 +655,8 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
public readonly providerId: string;
public readonly issuer: string;
public readonly clientId: string;
public readonly clientSecret?: string;
public readonly responseType: OAuthResponseType;

/**
* Converts a client side request to a OIDCConfigServerRequest which is the format
Expand All @@ -676,6 +683,12 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
request.displayName = options.displayName;
request.issuer = options.issuer;
request.clientId = options.clientId;
if (typeof options.clientSecret !== 'undefined') {
request.clientSecret = options.clientSecret;
}
if (typeof options.responseType !== 'undefined') {
request.responseType = options.responseType;
}
return request;
}

Expand Down Expand Up @@ -715,6 +728,12 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
providerId: true,
clientId: true,
issuer: true,
clientSecret: true,
responseType: true,
};
const validResponseTypes = {
idToken: true,
code: true,
};
if (!validator.isNonNullObject(options)) {
throw new FirebaseAuthError(
Expand Down Expand Up @@ -773,6 +792,61 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
'"OIDCAuthProviderConfig.displayName" must be a valid string.',
);
}
if (typeof options.clientSecret !== 'undefined' &&
xil222 marked this conversation as resolved.
Show resolved Hide resolved
!validator.isNonEmptyString(options.clientSecret)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
'"OIDCAuthProviderConfig.clientSecret" must be a valid string.',
);
}
if (validator.isNonNullObject(options.responseType) && typeof options.responseType !== 'undefined') {
Object.keys(options.responseType).forEach((key) => {
if (!(key in validResponseTypes)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CONFIG,
`"${key}" is not a valid OAuthResponseType parameter.`,
);
}
});

const idToken = options.responseType.idToken;
if (typeof idToken !== 'undefined') {
if (!validator.isBoolean(idToken)) {
xil222 marked this conversation as resolved.
Show resolved Hide resolved
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'"OIDCAuthProviderConfig.responseType.idToken" must be a boolean.',
);
}
}

const code = options.responseType.code;
if (typeof code !== 'undefined') {
if (!validator.isBoolean(code)) {
bojeil-google marked this conversation as resolved.
Show resolved Hide resolved
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'"OIDCAuthProviderConfig.responseType.code" must be a boolean.',
);
}

// If code flow is enabled, client secret must be provided.
xil222 marked this conversation as resolved.
Show resolved Hide resolved
if (typeof options.clientSecret === 'undefined') {
throw new FirebaseAuthError(
AuthClientErrorCode.MISSING_OAUTH_CLIENT_SECRET,
'The OAuth configuration client secret is required to enable OIDC code flow.',
);
}
}

const allKeys = Object.keys(options.responseType).length;
const enabledCount = Object.values(options.responseType).filter(Boolean).length;
// Only one of OAuth response types can be set to true.
if (allKeys > 1 && enabledCount != 1) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_OAUTH_RESPONSETYPE,
'Only exactly one OAuth responseType should be set to true.',
);
}
}
}

/**
Expand Down Expand Up @@ -806,6 +880,13 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
// When enabled is undefined, it takes its default value of false.
this.enabled = !!response.enabled;
this.displayName = response.displayName;

if (typeof response.clientSecret !== 'undefined') {
this.clientSecret = response.clientSecret;
}
if (typeof response.responseType !== 'undefined') {
this.responseType = response.responseType;
}
}

/** @return {OIDCAuthProviderConfig} The plain object representation of the OIDCConfig. */
Expand All @@ -816,6 +897,8 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
providerId: this.providerId,
issuer: this.issuer,
clientId: this.clientId,
clientSecret: this.clientSecret,
xil222 marked this conversation as resolved.
Show resolved Hide resolved
responseType: this.responseType,
xil222 marked this conversation as resolved.
Show resolved Hide resolved
};
}
}
40 changes: 40 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,25 @@ export namespace auth {
callbackURL?: string;
}

/**
* The interface representing OIDC provider's response object for OAuth
* authorization flow.
* We need either of them to be true, there are two cases:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment could be improved. Suggest:

" * One of the following must be true:

  • If code is set to true, then we are doing code flow.
  • If dToken is set to true, then we are doing ID token flow."

(Assuming that backticks are rendered as code font, and that "ID token flow" is a thing, separate from the literal idToken flag.

* If set code to true, then we are doing code flow.
* If set idToken to true, then we are doing idToken flow.
*/
export interface OAuthResponseType {
/**
* Whether ID token is returned from IdP's authorization endpoint.
*/
idToken?: boolean;

/**
* Whether authorization code is returned from IdP's authorization endpoint.
*/
code?: boolean;
}

/**
* The [OIDC](https://openid.net/specs/openid-connect-core-1_0-final.html) Auth
* provider configuration interface. An OIDC provider can be created via
Expand Down Expand Up @@ -1321,6 +1340,16 @@ export namespace auth {
* [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation).
*/
issuer: string;

/**
* The OIDC provider's client secret to enable OIDC code flow.
*/
clientSecret?: string;

/**
* The OIDC provider's response object for OAuth authorization flow.
*/
responseType?: OAuthResponseType;
}

/**
Expand Down Expand Up @@ -1403,6 +1432,17 @@ export namespace auth {
* configuration's value is not modified.
*/
issuer?: string;

/**
* The OIDC provider's client secret to enable OIDC code flow.
* If not provided, the existing configuration's value is not modified.
*/
clientSecret?: string;

/**
* The OIDC provider's response object for OAuth authorization flow.
*/
responseType?: OAuthResponseType;
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/utils/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@ export class AuthClientErrorCode {
code: 'invalid-provider-uid',
message: 'The providerUid must be a valid provider uid string.',
};
public static INVALID_OAUTH_RESPONSETYPE = {
code: 'invalid-oauth-responsetype',
message: 'Only exactly one OAuth responseType should be set to true.',
};
public static INVALID_SESSION_COOKIE_DURATION = {
code: 'invalid-session-cookie-duration',
message: 'The session cookie duration must be a valid number in milliseconds ' +
Expand Down Expand Up @@ -593,6 +597,10 @@ export class AuthClientErrorCode {
code: 'missing-oauth-client-id',
message: 'The OAuth/OIDC configuration client ID must not be empty.',
};
public static MISSING_OAUTH_CLIENT_SECRET = {
code: 'missing-oauth-client-secret',
message: 'The OAuth configuration client secret is required to enable OIDC code flow.',
};
public static MISSING_PROVIDER_ID = {
code: 'missing-provider-id',
message: 'A valid provider ID must be provided in the request.',
Expand Down
76 changes: 59 additions & 17 deletions test/integration/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,12 +1334,21 @@ describe('admin.auth', () => {
enabled: true,
issuer: 'https://oidc.com/issuer1',
clientId: 'CLIENT_ID1',
responseType: {
idToken: true,
code: false,
},
};
const modifiedConfigOptions = {
displayName: 'OIDC_DISPLAY_NAME3',
enabled: false,
issuer: 'https://oidc.com/issuer3',
clientId: 'CLIENT_ID3',
clientSecret: 'CLIENT_SECRET',
responseType: {
idToken: false,
code: true,
},
};

before(function() {
Expand Down Expand Up @@ -1633,13 +1642,20 @@ describe('admin.auth', () => {
enabled: true,
issuer: 'https://oidc.com/issuer1',
clientId: 'CLIENT_ID1',
responseType: {
idToken: true,
},
};
const authProviderConfig2 = {
providerId: randomOidcProviderId(),
displayName: 'OIDC_DISPLAY_NAME2',
enabled: true,
issuer: 'https://oidc.com/issuer2',
clientId: 'CLIENT_ID2',
clientSecret: 'CLIENT_SECRET',
responseType: {
code: true,
},
};

const removeTempConfigs = (): Promise<any> => {
Expand Down Expand Up @@ -1706,39 +1722,65 @@ describe('admin.auth', () => {
});
});

it('updateProviderConfig() successfully overwrites an OIDC config', () => {
it('updateProviderConfig() successfully partially modifies an OIDC config', () => {
const deltaChanges = {
displayName: 'OIDC_DISPLAY_NAME3',
enabled: false,
issuer: 'https://oidc.com/issuer3',
clientId: 'CLIENT_ID3',
clientSecret: 'CLIENT_SECRET',
responseType: {
idToken: false,
code: true,
},
};
// Only above fields should be modified.
const modifiedConfigOptions = {
providerId: authProviderConfig1.providerId,
displayName: 'OIDC_DISPLAY_NAME3',
hiranya911 marked this conversation as resolved.
Show resolved Hide resolved
enabled: false,
issuer: 'https://oidc.com/issuer3',
clientId: 'CLIENT_ID3',
clientSecret: 'CLIENT_SECRET',
responseType: {
code: true,
},
};
return admin.auth().updateProviderConfig(authProviderConfig1.providerId, modifiedConfigOptions)
return admin.auth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges)
.then((config) => {
const modifiedConfig = deepExtend(
{ providerId: authProviderConfig1.providerId }, modifiedConfigOptions);
assertDeepEqualUnordered(modifiedConfig, config);
assertDeepEqualUnordered(modifiedConfigOptions, config);
});
});

it('updateProviderConfig() successfully partially modifies an OIDC config', () => {
it('updateProviderConfig() with invalid oauth response type should be rejected', () => {
const deltaChanges = {
displayName: 'OIDC_DISPLAY_NAME4',
enabled: false,
issuer: 'https://oidc.com/issuer4',
clientId: 'CLIENT_ID4',
clientSecret: 'CLIENT_SECRET',
responseType: {
idToken: false,
code: false,
},
};
// Only above fields should be modified.
const modifiedConfigOptions = {
displayName: 'OIDC_DISPLAY_NAME4',
return admin.auth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges).
should.eventually.be.rejected.and.have.property('code', 'auth/invalid-oauth-responsetype');
});

it('updateProviderConfig() code flow with no client secret should be rejected', () => {
const deltaChanges = {
displayName: 'OIDC_DISPLAY_NAME5',
enabled: false,
issuer: 'https://oidc.com/issuer4',
clientId: 'CLIENT_ID3',
issuer: 'https://oidc.com/issuer5',
clientId: 'CLIENT_ID5',
responseType: {
idToken: false,
code: true,
},
};
return admin.auth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges)
.then((config) => {
const modifiedConfig = deepExtend(
{ providerId: authProviderConfig1.providerId }, modifiedConfigOptions);
assertDeepEqualUnordered(modifiedConfig, config);
});
return admin.auth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges).
should.eventually.be.rejected.and.have.property('code', 'auth/missing-oauth-client-secret');
});

it('deleteProviderConfig() successfully deletes an existing OIDC config', () => {
Expand Down
Loading