fix: Update OIDC provider dependency to v7

The biggest resulting change is that the consent page always appears
after logging in.
Some minor fixes to be closer to the spec are included
together with some minor structural refactors.
This commit is contained in:
Joachim Van Herwegen
2022-02-15 16:58:36 +01:00
parent 1769b799df
commit c9ed90aeeb
32 changed files with 1081 additions and 661 deletions

View File

@@ -20,8 +20,8 @@ export class OidcHttpHandler extends HttpHandler {
const provider = await this.providerFactory.getProvider();
this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
// Even though the typings do not indicate this, this is a Promise that needs to be awaited.
// Otherwise the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response.
// Otherwise, the `BaseHttpServerFactory` will write a 404 before the OIDC library could handle the response.
// eslint-disable-next-line @typescript-eslint/await-thenable
await provider.callback(request, response);
await provider.callback()(request, response);
}
}

View File

@@ -4,13 +4,14 @@
import { randomBytes } from 'crypto';
import type { JWK } from 'jose';
import { exportJWK, generateKeyPair } from 'jose';
import type { AnyObject,
import type { Account,
Adapter,
CanBePromise,
KoaContextWithOIDC,
Configuration,
Account,
ErrorOut,
Adapter } from 'oidc-provider';
KoaContextWithOIDC,
ResourceServer,
UnknownObject } from 'oidc-provider';
import { Provider } from 'oidc-provider';
import type { Operation } from '../../http/Operation';
import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
@@ -75,6 +76,7 @@ export class IdentityProviderFactory implements ProviderFactory {
private readonly errorHandler!: ErrorHandler;
private readonly responseWriter!: ResponseWriter;
private readonly jwtAlg = 'ES256';
private provider?: Provider;
/**
@@ -139,11 +141,23 @@ export class IdentityProviderFactory implements ProviderFactory {
keys: await this.generateCookieKeys(),
};
// Solid OIDC requires pkce https://solid.github.io/solid-oidc/#concepts
config.pkce = {
methods: [ 'S256' ],
required: (): true => true,
};
// Default client settings that might not be defined.
// Mostly relevant for WebID clients.
config.clientDefaults = {
id_token_signed_response_alg: this.jwtAlg,
};
return config;
}
/**
* Generates a JWKS using a single RS256 JWK..
* Generates a JWKS using a single JWK.
* The JWKS will be cached so subsequent calls return the same key.
*/
private async generateJwks(): Promise<{ keys: JWK[] }> {
@@ -153,10 +167,10 @@ export class IdentityProviderFactory implements ProviderFactory {
return jwks;
}
// If they are not, generate and save them
const { privateKey } = await generateKeyPair('RS256');
const { privateKey } = await generateKeyPair(this.jwtAlg);
const jwk = await exportJWK(privateKey);
// Required for Solid authn client
jwk.alg = 'RS256';
jwk.alg = this.jwtAlg;
// In node v15.12.0 the JWKS does not get accepted because the JWK is not a plain object,
// which is why we convert it into a plain object here.
// Potentially this can be changed at a later point in time to `{ keys: [ jwk ]}`.
@@ -190,28 +204,51 @@ export class IdentityProviderFactory implements ProviderFactory {
}
/**
* Adds the necessary claims the to id token and access token based on the Solid OIDC spec.
* Adds the necessary claims the to id and access tokens based on the Solid OIDC spec.
*/
private configureClaims(config: Configuration): void {
// Access token audience is 'solid', ID token audience is the client_id
config.audiences = (ctx, sub, token, use): string =>
use === 'access_token' ? 'solid' : token.clientId!;
// Returns the id_token
// See https://solid.github.io/authentication-panel/solid-oidc/#tokens-id
// Some fields are still missing, see https://github.com/solid/community-server/issues/1154#issuecomment-1040233385
config.findAccount = async(ctx: KoaContextWithOIDC, sub: string): Promise<Account> => ({
accountId: sub,
claims: async(): Promise<{ sub: string; [key: string]: any }> =>
({ sub, webid: sub }),
async claims(): Promise<{ sub: string; [key: string]: any }> {
return { sub, webid: sub, azp: ctx.oidc.client?.clientId };
},
});
// Add extra claims in case an AccessToken is being issued.
// Specifically this sets the required webid and client_id claims for the access token
// See https://solid.github.io/authentication-panel/solid-oidc/#tokens-access
config.extraAccessTokenClaims = (ctx, token): CanBePromise<AnyObject | void> =>
// See https://solid.github.io/solid-oidc/#resource-access-validation
config.extraTokenClaims = (ctx, token): CanBePromise<UnknownObject> =>
this.isAccessToken(token) ?
{ webid: token.accountId, client_id: token.clientId } :
{ webid: token.accountId } :
{};
config.features = {
...config.features,
resourceIndicators: {
defaultResource(): string {
// This value is irrelevant, but is necessary to trigger the `getResourceServerInfo` call below,
// where it will be an input parameter in case the client provided no value.
// Note that an empty string is not a valid value.
return 'http://example.com/';
},
enabled: true,
// This call is necessary to force the OIDC library to return a JWT access token.
// See https://github.com/panva/node-oidc-provider/discussions/959#discussioncomment-524757
getResourceServerInfo: (): ResourceServer => ({
// The scopes of the Resource Server.
// Since this is irrelevant at the moment, an empty string is fine.
scope: '',
audience: 'solid',
accessTokenFormat: 'jwt',
jwt: {
sign: { alg: this.jwtAlg },
},
}),
},
};
}
/**
@@ -230,6 +267,7 @@ export class IdentityProviderFactory implements ProviderFactory {
// When oidc-provider cannot fulfill the authorization request for any of the possible reasons
// (missing user session, requested ACR not fulfilled, prompt requested, ...)
// it will resolve the interactions.url helper function and redirect the User-Agent to that url.
// Another requirement is that `features.userinfo` is disabled in the configuration.
config.interactions = {
url: async(ctx, oidcInteraction): Promise<string> => {
const operation: Operation = {
@@ -255,7 +293,7 @@ export class IdentityProviderFactory implements ProviderFactory {
config.routes = {
authorization: this.createRoute('auth'),
check_session: this.createRoute('session/check'),
backchannel_authentication: this.createRoute('backchannel'),
code_verification: this.createRoute('device'),
device_authorization: this.createRoute('device/auth'),
end_session: this.createRoute('session/end'),

View File

@@ -1,48 +0,0 @@
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../util/errors/FoundHttpError';
import { BaseInteractionHandler } from './BaseInteractionHandler';
import type { InteractionHandlerInput } from './InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter';
/**
* Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}.
* This is required by handlers that handle IDP behaviour
* and need to complete an OIDC interaction by redirecting back to the client,
* such as when logging in.
*
* Calls the InteractionCompleter with the results returned by the helper function
* and throw a corresponding {@link FoundHttpError}.
*/
export abstract class CompletingInteractionHandler extends BaseInteractionHandler {
protected readonly interactionCompleter: InteractionCompleter;
protected constructor(view: Record<string, unknown>, interactionCompleter: InteractionCompleter) {
super(view);
this.interactionCompleter = interactionCompleter;
}
public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input);
if (input.operation.method === 'POST' && !input.oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
);
}
}
public async handlePost(input: InteractionHandlerInput): Promise<never> {
// Interaction is defined due to canHandle call
const parameters = await this.getCompletionParameters(input as Required<InteractionHandlerInput>);
const location = await this.interactionCompleter.handleSafe(parameters);
throw new FoundHttpError(location);
}
/**
* Generates the parameters necessary to call an InteractionCompleter.
* The input parameters are the same that the `handlePost` function was called with.
* @param input - The original input parameters to the `handle` function.
*/
protected abstract getCompletionParameters(input: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput>;
}

View File

@@ -0,0 +1,111 @@
import type { InteractionResults, KoaContextWithOIDC, UnknownObject } from 'oidc-provider';
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../util/StreamUtil';
import type { ProviderFactory } from '../configuration/ProviderFactory';
import { BaseInteractionHandler } from './BaseInteractionHandler';
import type { Interaction, InteractionHandlerInput } from './InteractionHandler';
type Grant = NonNullable<KoaContextWithOIDC['oidc']['entities']['Grant']>;
/**
* Handles the OIDC consent prompts where the user confirms they want to log in for the given client.
*/
export class ConsentHandler extends BaseInteractionHandler {
private readonly providerFactory: ProviderFactory;
public constructor(providerFactory: ProviderFactory) {
super({});
this.providerFactory = providerFactory;
}
public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input);
if (input.operation.method === 'POST' && !input.oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
);
}
}
protected async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise<never> {
const { remember } = await readJsonStream(operation.body.data);
const grant = await this.getGrant(oidcInteraction!);
this.updateGrant(grant, oidcInteraction!.prompt.details, remember);
const location = await this.updateInteraction(oidcInteraction!, grant);
throw new FoundHttpError(location);
}
/**
* Either returns the grant associated with the given interaction or creates a new one if it does not exist yet.
*/
private async getGrant(oidcInteraction: Interaction): Promise<Grant> {
if (!oidcInteraction.session) {
throw new NotImplementedHttpError('Only interactions with a valid session are supported.');
}
const { params, session: { accountId }, grantId } = oidcInteraction;
const provider = await this.providerFactory.getProvider();
let grant: Grant;
if (grantId) {
grant = (await provider.Grant.find(grantId))!;
} else {
grant = new provider.Grant({
accountId,
clientId: params.client_id as string,
});
}
return grant;
}
/**
* Updates the grant with all the missing scopes and claims requested by the interaction.
*
* Will reject the `offline_access` scope if `remember` is false.
*/
private updateGrant(grant: Grant, details: UnknownObject, remember: boolean): void {
// Reject the offline_access scope if the user does not want to be remembered
if (!remember) {
grant.rejectOIDCScope('offline_access');
}
// Grant all the requested scopes and claims
if (details.missingOIDCScope) {
grant.addOIDCScope((details.missingOIDCScope as string[]).join(' '));
}
if (details.missingOIDCClaims) {
grant.addOIDCClaims(details.missingOIDCClaims as string[]);
}
if (details.missingResourceScopes) {
for (const [ indicator, scopes ] of Object.entries(details.missingResourceScopes as Record<string, string[]>)) {
grant.addResourceScope(indicator, scopes.join(' '));
}
}
}
/**
* Updates the interaction with the new grant and returns the resulting redirect URL.
*/
private async updateInteraction(oidcInteraction: Interaction, grant: Grant): Promise<string> {
const grantId = await grant.save();
const consent: InteractionResults['consent'] = {};
// Only need to update the grantId if it is new
if (!oidcInteraction.grantId) {
consent.grantId = grantId;
}
const result: InteractionResults = { consent };
// Need to merge with previous submission
oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result };
await oidcInteraction.save(oidcInteraction.exp - Math.floor(Date.now() / 1000));
return oidcInteraction.returnTo;
}
}

View File

@@ -1,25 +0,0 @@
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
import { readJsonStream } from '../../util/StreamUtil';
import { CompletingInteractionHandler } from './CompletingInteractionHandler';
import type { InteractionHandlerInput } from './InteractionHandler';
import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter';
/**
* Simple CompletingInteractionRoute that returns the session accountId as webId.
* This is relevant when a client already logged in this session and tries logging in again.
*/
export class ExistingLoginHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super({}, interactionCompleter);
}
protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> {
if (!oidcInteraction.session) {
throw new NotImplementedHttpError('Only interactions with a valid session are supported.');
}
const { remember } = await readJsonStream(operation.body.data);
return { oidcInteraction, webId: oidcInteraction.session.accountId, shouldRemember: Boolean(remember) };
}
}

View File

@@ -8,8 +8,8 @@ import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { InteractionRoute } from '../../routing/InteractionRoute';
import type { EmailSender } from '../../util/EmailSender';
import type { AccountStore } from '../storage/AccountStore';
import type { EmailSender } from '../util/EmailSender';
const forgotPasswordView = {
required: {

View File

@@ -1,11 +1,12 @@
import assert from 'assert';
import type { InteractionResults } from 'oidc-provider';
import type { Operation } from '../../../../http/Operation';
import { getLoggerFor } from '../../../../logging/LogUtil';
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
import { FoundHttpError } from '../../../../util/errors/FoundHttpError';
import { readJsonStream } from '../../../../util/StreamUtil';
import { CompletingInteractionHandler } from '../../CompletingInteractionHandler';
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
import type { InteractionHandlerInput } from '../../InteractionHandler';
import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter';
import type { AccountStore } from '../storage/AccountStore';
const loginView = {
@@ -26,19 +27,27 @@ interface LoginInput {
* Handles the submission of the Login Form and logs the user in.
* Will throw a RedirectHttpError on success.
*/
export class LoginHandler extends CompletingInteractionHandler {
export class LoginHandler extends BaseInteractionHandler {
protected readonly logger = getLoggerFor(this);
private readonly accountStore: AccountStore;
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
super(loginView, interactionCompleter);
public constructor(accountStore: AccountStore) {
super(loginView);
this.accountStore = accountStore;
}
protected async getCompletionParameters(input: Required<InteractionHandlerInput>):
Promise<InteractionCompleterInput> {
const { operation, oidcInteraction } = input;
public async canHandle(input: InteractionHandlerInput): Promise<void> {
await super.canHandle(input);
if (input.operation.method === 'POST' && !input.oidcInteraction) {
throw new BadRequestHttpError(
'This action can only be performed as part of an OIDC authentication flow.',
{ errorCode: 'E0002' },
);
}
}
public async handlePost({ operation, oidcInteraction }: InteractionHandlerInput): Promise<never> {
const { email, password, remember } = await this.parseInput(operation);
// Try to log in, will error if email/password combination is invalid
const webId = await this.accountStore.authenticate(email, password);
@@ -49,7 +58,15 @@ export class LoginHandler extends CompletingInteractionHandler {
}
this.logger.debug(`Logging in user ${email}`);
return { oidcInteraction, webId, shouldRemember: remember };
// Update the interaction to get the redirect URL
const login: InteractionResults['login'] = {
accountId: webId,
remember,
};
oidcInteraction!.result = { login };
await oidcInteraction!.save(oidcInteraction!.exp - Math.floor(Date.now() / 1000));
throw new FoundHttpError(oidcInteraction!.returnTo);
}
/**

View File

@@ -1,4 +1,4 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
export interface EmailArgs {
recipient: string;

View File

@@ -1,37 +0,0 @@
import type { InteractionResults } from 'oidc-provider';
import type { InteractionCompleterInput } from './InteractionCompleter';
import { InteractionCompleter } from './InteractionCompleter';
/**
* Creates a simple InteractionResults object based on the input parameters and injects it in the Interaction.
*/
export class BaseInteractionCompleter extends InteractionCompleter {
public async handle(input: InteractionCompleterInput): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const result: InteractionResults = {
login: {
account: input.webId,
// Indicates if a persistent cookie should be used instead of a session cookie.
remember: input.shouldRemember,
ts: now,
},
consent: {
// When OIDC clients want a refresh token, they need to request the 'offline_access' scope.
// This indicates that this scope is not granted to the client in case they do not want to be remembered.
rejectedScopes: input.shouldRemember ? [] : [ 'offline_access' ],
},
};
// Generates the URL a client needs to be redirected to
// after a successful interaction completion (such as logging in).
// Identical behaviour to calling `provider.interactionResult`.
// We use the code below instead of calling that function
// since that function also uses Request/Response objects to generate the Interaction object,
// which we already have here.
const { oidcInteraction } = input;
oidcInteraction.result = { ...oidcInteraction.lastSubmission, ...result };
await oidcInteraction.save(oidcInteraction.exp - now);
return oidcInteraction.returnTo;
}
}

View File

@@ -1,16 +0,0 @@
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { Interaction } from '../InteractionHandler';
/**
* Parameters required to specify how the interaction should be completed.
*/
export interface InteractionCompleterInput {
oidcInteraction: Interaction;
webId: string;
shouldRemember?: boolean;
}
/**
* Class responsible for completing the interaction based on the parameters provided.
*/
export abstract class InteractionCompleter extends AsyncHandler<InteractionCompleterInput, string> {}

View File

@@ -139,6 +139,8 @@ export * from './identity/interaction/email-password/storage/AccountStore';
export * from './identity/interaction/email-password/storage/BaseAccountStore';
// Identity/Interaction/Email-Password/Util
export * from './identity/interaction/email-password/util/BaseEmailSender';
export * from './identity/interaction/email-password/util/EmailSender';
export * from './identity/interaction/email-password/util/RegistrationManager';
// Identity/Interaction/Email-Password
@@ -150,16 +152,9 @@ export * from './identity/interaction/routing/InteractionRoute';
export * from './identity/interaction/routing/InteractionRouteHandler';
export * from './identity/interaction/routing/RelativePathInteractionRoute';
// Identity/Interaction/Util
export * from './identity/interaction/util/BaseEmailSender';
export * from './identity/interaction/util/BaseInteractionCompleter';
export * from './identity/interaction/util/EmailSender';
export * from './identity/interaction/util/InteractionCompleter';
// Identity/Interaction
export * from './identity/interaction/BaseInteractionHandler';
export * from './identity/interaction/CompletingInteractionHandler';
export * from './identity/interaction/ExistingLoginHandler';
export * from './identity/interaction/ConsentHandler';
export * from './identity/interaction/ControlHandler';
export * from './identity/interaction/FixedInteractionHandler';
export * from './identity/interaction/HtmlViewHandler';