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

@ -1,7 +1,7 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
"import": [
"files-scs:config/identity/handler/interaction/routes/existing-login.json",
"files-scs:config/identity/handler/interaction/routes/consent.json",
"files-scs:config/identity/handler/interaction/routes/forgot-password.json",
"files-scs:config/identity/handler/interaction/routes/index.json",
"files-scs:config/identity/handler/interaction/routes/login.json",
@ -47,7 +47,7 @@
{ "@id": "urn:solid-server:auth:password:IndexRouteHandler" },
{ "@id": "urn:solid-server:auth:password:PromptRouteHandler" },
{ "@id": "urn:solid-server:auth:password:LoginRouteHandler" },
{ "@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler" },
{ "@id": "urn:solid-server:auth:password:ConsentRouteHandler" },
{ "@id": "urn:solid-server:auth:password:ForgotPasswordRouteHandler" },
{ "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }
]

View File

@ -3,18 +3,18 @@
"@graph": [
{
"comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
"@id": "urn:solid-server:auth:password:ExistingLoginRouteHandler",
"@id": "urn:solid-server:auth:password:ConsentRouteHandler",
"@type":"InteractionRouteHandler",
"route": {
"@id": "urn:solid-server:auth:password:ExistingLoginRoute",
"@id": "urn:solid-server:auth:password:ConsentRoute",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
"relativePath": "/consent/"
},
"source": {
"@id": "urn:solid-server:auth:password:ExistingLoginHandler",
"@type": "ExistingLoginHandler",
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
"@id": "urn:solid-server:auth:password:ConsentHandler",
"@type": "ConsentHandler",
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
}
}
]

View File

@ -14,8 +14,7 @@
"source": {
"@id": "urn:solid-server:auth:password:LoginHandler",
"@type": "LoginHandler",
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
}
}
]

View File

@ -21,7 +21,7 @@
},
{
"PromptHandler:_promptRoutes_key": "consent",
"PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }
"PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" }
}
]
}

View File

@ -28,7 +28,7 @@
},
{
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs",
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }
"HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" }
},
{
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs",

View File

@ -17,7 +17,7 @@
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
"config": {
"claims": {
"openid": [ "client_id" ],
"openid": [ "azp" ],
"webid": [ "webid" ]
},
"cookies": {
@ -27,22 +27,24 @@
"features": {
"claimsParameter": { "enabled": true },
"devInteractions": { "enabled": false },
"dPoP": { "enabled": true, "ack": "draft-01" },
"dPoP": { "enabled": true, "ack": "draft-03" },
"introspection": { "enabled": true },
"registration": { "enabled": true },
"revocation": { "enabled": true }
},
"formats": {
"AccessToken": "jwt"
"revocation": { "enabled": true },
"userinfo": { "enabled": false }
},
"scopes": [ "openid", "profile", "offline_access", "webid" ],
"subjectTypes": [ "public" ],
"ttl": {
"AccessToken": 3600,
"AuthorizationCode": 600,
"BackchannelAuthenticationRequest": 600,
"DeviceCode": 600,
"Grant": 1209600,
"IdToken": 3600,
"RefreshToken": 86400
"Interaction": 3600,
"RefreshToken": 86400,
"Session": 1209600
}
}
}

894
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,7 @@
"@types/n3": "^1.10.4",
"@types/node": "^14.18.0",
"@types/nodemailer": "^6.4.4",
"@types/oidc-provider": "^7.8.1",
"@types/pump": "^1.1.1",
"@types/punycode": "^2.1.0",
"@types/redis": "^2.8.30",
@ -114,7 +115,7 @@
"mime-types": "^2.1.34",
"n3": "^1.13.0",
"nodemailer": "^6.7.2",
"oidc-provider": "^6.31.1",
"oidc-provider": "^7.10.6",
"pump": "^3.0.0",
"punycode": "^2.1.1",
"rdf-dereference": "^1.9.0",

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';

View File

@ -138,10 +138,11 @@ describe('A Solid server with IDP', (): void => {
});
it('initializes the session and logs in.', async(): Promise<void> => {
const url = await state.startSession();
let url = await state.startSession();
const res = await state.fetchIdp(url);
expect(res.status).toBe(200);
await state.login(url, email, password);
url = await state.login(url, email, password);
await state.consent(url);
expect(state.session.info?.webId).toBe(webId);
});
@ -162,16 +163,12 @@ describe('A Solid server with IDP', (): void => {
it('can log in again.', async(): Promise<void> => {
const url = await state.startSession();
let res = await state.fetchIdp(url);
const res = await state.fetchIdp(url);
expect(res.status).toBe(200);
// Will receive confirm screen here instead of login screen
res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
const json = await res.json();
const nextUrl = json.location;
expect(typeof nextUrl).toBe('string');
await state.consent(url);
await state.handleLoginRedirect(nextUrl);
expect(state.session.info?.webId).toBe(webId);
});
});
@ -223,10 +220,11 @@ describe('A Solid server with IDP', (): void => {
});
it('initializes the session and logs in.', async(): Promise<void> => {
const url = await state.startSession(clientId);
let url = await state.startSession(clientId);
const res = await state.fetchIdp(url);
expect(res.status).toBe(200);
await state.login(url, email, password);
url = await state.login(url, email, password);
await state.consent(url);
expect(state.session.info?.webId).toBe(webId);
});
@ -318,7 +316,8 @@ describe('A Solid server with IDP', (): void => {
});
it('can log in with the new password.', async(): Promise<void> => {
await state.login(nextUrl, email, password2);
const url = await state.login(nextUrl, email, password2);
await state.consent(url);
expect(state.session.info?.webId).toBe(webId);
});
});
@ -397,10 +396,11 @@ describe('A Solid server with IDP', (): void => {
it('initializes the session and logs in.', async(): Promise<void> => {
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
const url = await state.startSession();
let url = await state.startSession();
const res = await state.fetchIdp(url);
expect(res.status).toBe(200);
await state.login(url, newMail, password);
url = await state.login(url, newMail, password);
await state.consent(url);
expect(state.session.info?.webId).toBe(newWebId);
});

View File

@ -89,7 +89,7 @@ export class IdentityTestState {
// Need to catch the redirect so we can copy the cookies
let res = await this.fetchIdp(nextUrl);
expect(res.status).toBe(302);
expect(res.status).toBe(303);
nextUrl = res.headers.get('location')!;
// Handle redirect
@ -109,22 +109,26 @@ export class IdentityTestState {
* Logs in by sending the corresponding email and password to the given form action.
* The URL should be extracted from the login page.
*/
public async login(url: string, email: string, password: string): Promise<void> {
public async login(url: string, email: string, password: string): Promise<string> {
const formData = stringify({ email, password });
const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
let res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
expect(res.status).toBe(200);
const json = await res.json();
const nextUrl = json.location;
return this.handleLoginRedirect(nextUrl);
res = await this.fetchIdp(json.location);
expect(res.status).toBe(303);
return res.headers.get('location')!;
}
/**
* Handles the redirect that happens after logging in.
* Handles the consent screen at the given URL and the followup redirect back to the client.
*/
public async handleLoginRedirect(url: string): Promise<void> {
const res = await this.fetchIdp(url);
expect(res.status).toBe(302);
public async consent(url: string): Promise<void> {
let res = await this.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
expect(res.status).toBe(200);
const json = await res.json();
res = await this.fetchIdp(json.location);
expect(res.status).toBe(303);
const mockUrl = res.headers.get('location')!;
expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy();

View File

@ -94,10 +94,11 @@ describe('A server with restricted IDP access', (): void => {
it('can still access registration with the correct credentials.', async(): Promise<void> => {
// Logging into session
const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl);
const url = await state.startSession();
let url = await state.startSession();
let res = await state.fetchIdp(url);
expect(res.status).toBe(200);
await state.login(url, settings.email, settings.password);
url = await state.login(url, settings.email, settings.password);
await state.consent(url);
expect(state.session.info?.webId).toBe(webId);
// Registration still works for this WebID

View File

@ -13,7 +13,7 @@ describe('An OidcHttpHandler', (): void => {
beforeEach(async(): Promise<void> => {
provider = {
callback: jest.fn(),
callback: jest.fn().mockReturnValue(jest.fn()),
} as any;
providerFactory = {
@ -26,6 +26,7 @@ describe('An OidcHttpHandler', (): void => {
it('sends all requests to the OIDC library.', async(): Promise<void> => {
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
expect(provider.callback).toHaveBeenCalledTimes(1);
expect(provider.callback).toHaveBeenLastCalledWith(request, response);
expect(provider.callback.mock.results[0].value).toHaveBeenCalledTimes(1);
expect(provider.callback.mock.results[0].value).toHaveBeenLastCalledWith(request, response);
});
});

View File

@ -16,7 +16,7 @@ jest.mock('oidc-provider', (): any => ({
const routes = {
authorization: '/foo/oidc/auth',
check_session: '/foo/oidc/session/check',
backchannel_authentication: '/foo/oidc/backchannel',
code_verification: '/foo/oidc/device',
device_authorization: '/foo/oidc/device/auth',
end_session: '/foo/oidc/session/end',
@ -100,23 +100,32 @@ describe('An IdentityProviderFactory', (): void => {
expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
expect(config.cookies?.keys).toEqual([ expect.any(String) ]);
expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]});
expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ alg: 'ES256' }) ]});
expect(config.routes).toEqual(routes);
expect(config.pkce?.methods).toEqual([ 'S256' ]);
expect((config.pkce!.required as any)()).toBe(true);
expect(config.clientDefaults?.id_token_signed_response_alg).toBe('ES256');
await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl);
expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid');
expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId');
const findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId);
let findResult = await config.findAccount?.({ oidc: { client: { clientId: 'clientId' }}} as any, webId);
expect(findResult?.accountId).toBe(webId);
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId, azp: 'clientId' });
findResult = await config.findAccount?.({ oidc: {}} as any, webId);
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId });
expect((config.extraAccessTokenClaims as any)({}, {})).toEqual({});
expect((config.extraAccessTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
.toEqual({
webid: webId,
client_id: 'clientId',
});
expect((config.extraTokenClaims as any)({}, {})).toEqual({});
expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
.toEqual({ webid: webId });
expect(config.features?.resourceIndicators?.enabled).toBe(true);
expect((config.features?.resourceIndicators?.defaultResource as any)()).toBe('http://example.com/');
expect((config.features?.resourceIndicators?.getResourceServerInfo as any)()).toEqual({
scope: '',
audience: 'solid',
accessTokenFormat: 'jwt',
jwt: { sign: { alg: 'ES256' }},
});
// Test the renderError function
const response = { } as HttpResponse;

View File

@ -1,76 +0,0 @@
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler';
import type {
Interaction,
InteractionHandlerInput,
} from '../../../../src/identity/interaction/InteractionHandler';
import type {
InteractionCompleter,
InteractionCompleterInput,
} from '../../../../src/identity/interaction/util/InteractionCompleter';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
const webId = 'http://alice.test.com/card#me';
class DummyCompletingInteractionHandler extends CompletingInteractionHandler {
public constructor(interactionCompleter: InteractionCompleter) {
super({}, interactionCompleter);
}
public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
return { webId, oidcInteraction: input.oidcInteraction };
}
}
describe('A CompletingInteractionHandler', (): void => {
const oidcInteraction: Interaction = {} as any;
const location = 'http://test.com/redirect';
let operation: Operation;
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: DummyCompletingInteractionHandler;
beforeEach(async(): Promise<void> => {
const representation = new BasicRepresentation('', 'application/json');
operation = {
method: 'POST',
body: representation,
} as any;
interactionCompleter = {
handleSafe: jest.fn().mockResolvedValue(location),
} as any;
handler = new DummyCompletingInteractionHandler(interactionCompleter);
});
it('calls the parent JSON canHandle check.', async(): Promise<void> => {
operation.body.metadata.contentType = 'application/x-www-form-urlencoded';
await expect(handler.canHandle({ operation, oidcInteraction } as any)).rejects.toThrow(NotImplementedHttpError);
});
it('can handle GET requests without interaction.', async(): Promise<void> => {
operation.method = 'GET';
await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined();
});
it('errors if no OidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation })).rejects.toThrow(error);
await expect(handler.canHandle({ operation, oidcInteraction })).resolves.toBeUndefined();
});
it('throws a redirect error with the completer location.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 302,
location,
});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId });
});
});

View File

@ -0,0 +1,142 @@
import type { Provider } from 'oidc-provider';
import type { ProviderFactory } from '../../../../src/identity/configuration/ProviderFactory';
import { ConsentHandler } from '../../../../src/identity/interaction/ConsentHandler';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { createPostJsonOperation } from './email-password/handler/Util';
const newGrantId = 'newGrantId';
class DummyGrant {
public accountId: string;
public clientId: string;
public readonly scopes: string[] = [];
public claims: string[] = [];
public readonly rejectedScopes: string[] = [];
public readonly resourceScopes: Record<string, string> = {};
public constructor(props: { accountId: string; clientId: string }) {
this.accountId = props.accountId;
this.clientId = props.clientId;
}
public rejectOIDCScope(scope: string): void {
this.rejectedScopes.push(scope);
}
public addOIDCScope(scope: string): void {
this.scopes.push(scope);
}
public addOIDCClaims(claims: string[]): void {
this.claims = claims;
}
public addResourceScope(resource: string, scope: string): void {
this.resourceScopes[resource] = scope;
}
public async save(): Promise<string> {
return newGrantId;
}
}
describe('A ConsentHandler', (): void => {
const accountId = 'http://example.com/id#me';
const clientId = 'clientId';
let grantFn: jest.Mock<DummyGrant> & { find: jest.Mock<DummyGrant> };
let knownGrant: DummyGrant;
let oidcInteraction: Interaction;
let provider: jest.Mocked<Provider>;
let providerFactory: jest.Mocked<ProviderFactory>;
let handler: ConsentHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
session: { accountId },
// eslint-disable-next-line @typescript-eslint/naming-convention
params: { client_id: clientId },
prompt: { details: {}},
save: jest.fn(),
} as any;
knownGrant = new DummyGrant({ accountId, clientId });
grantFn = jest.fn((props): DummyGrant => new DummyGrant(props)) as any;
grantFn.find = jest.fn((grantId: string): any => grantId ? knownGrant : undefined);
provider = {
// eslint-disable-next-line @typescript-eslint/naming-convention
Grant: grantFn,
} as any;
providerFactory = {
getProvider: jest.fn().mockResolvedValue(provider),
};
handler = new ConsentHandler(providerFactory);
});
it('errors if no oidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error);
await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction }))
.resolves.toBeUndefined();
});
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined;
await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
.rejects.toThrow(NotImplementedHttpError);
});
it('throws a redirect error.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
});
it('stores the requested scopes and claims in the grant.', async(): Promise<void> => {
oidcInteraction.prompt.details = {
missingOIDCScope: [ 'scope1', 'scope2' ],
missingOIDCClaims: [ 'claim1', 'claim2' ],
missingResourceScopes: { resource: [ 'scope1', 'scope2' ]},
};
const operation = createPostJsonOperation({ remember: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.scopes).toEqual([ 'scope1 scope2' ]);
expect(grantFn.mock.results[0].value.claims).toEqual([ 'claim1', 'claim2' ]);
expect(grantFn.mock.results[0].value.resourceScopes).toEqual({ resource: 'scope1 scope2' });
expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([]);
});
it('creates a new Grant when needed.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(1);
expect(grantFn).toHaveBeenLastCalledWith({ accountId, clientId });
expect(grantFn.find).toHaveBeenCalledTimes(0);
});
it('reuses existing Grant objects.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
oidcInteraction.grantId = '123456';
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn).toHaveBeenCalledTimes(0);
expect(grantFn.find).toHaveBeenCalledTimes(1);
expect(grantFn.find).toHaveBeenLastCalledWith('123456');
});
it('rejectes offline_access as scope if a user does not want to be remembered.', async(): Promise<void> => {
const operation = createPostJsonOperation({});
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(grantFn.mock.results).toHaveLength(1);
expect(grantFn.mock.results[0].value.rejectedScopes).toEqual([ 'offline_access' ]);
});
});

View File

@ -1,38 +0,0 @@
import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler';
import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
import type {
InteractionCompleter,
} from '../../../../src/identity/interaction/util/InteractionCompleter';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
import { createPostJsonOperation } from './email-password/handler/Util';
describe('An ExistingLoginHandler', (): void => {
const webId = 'http://test.com/id#me';
let oidcInteraction: Interaction;
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: ExistingLoginHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = { session: { accountId: webId }} as any;
interactionCompleter = {
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
} as any;
handler = new ExistingLoginHandler(interactionCompleter);
});
it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
oidcInteraction.session = undefined;
await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
.rejects.toThrow(NotImplementedHttpError);
});
it('returns the correct completion parameters.', async(): Promise<void> => {
const operation = createPostJsonOperation({ remember: true });
await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true });
});
});

View File

@ -3,8 +3,8 @@ import {
ForgotPasswordHandler,
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
import type { EmailSender } from '../../../../../../src/identity/interaction/email-password/util/EmailSender';
import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute';
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
import { createPostJsonOperation } from './Util';

View File

@ -4,22 +4,23 @@ import type {
Interaction,
InteractionHandlerInput,
} from '../../../../../../src/identity/interaction/InteractionHandler';
import type {
InteractionCompleter,
} from '../../../../../../src/identity/interaction/util/InteractionCompleter';
import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
import { createPostJsonOperation } from './Util';
describe('A LoginHandler', (): void => {
const webId = 'http://alice.test.com/card#me';
const email = 'alice@test.email';
const oidcInteraction: Interaction = {} as any;
let oidcInteraction: jest.Mocked<Interaction>;
let input: Required<InteractionHandlerInput>;
let accountStore: jest.Mocked<AccountStore>;
let interactionCompleter: jest.Mocked<InteractionCompleter>;
let handler: LoginHandler;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
exp: 123456,
save: jest.fn(),
} as any;
input = { oidcInteraction } as any;
accountStore = {
@ -27,11 +28,18 @@ describe('A LoginHandler', (): void => {
getSettings: jest.fn().mockResolvedValue({ useIdp: true }),
} as any;
interactionCompleter = {
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
} as any;
handler = new LoginHandler(accountStore);
});
it('errors if no oidcInteraction is defined on POST requests.', async(): Promise<void> => {
const error = expect.objectContaining({
statusCode: 400,
message: 'This action can only be performed as part of an OIDC authentication flow.',
errorCode: 'E0002',
});
await expect(handler.canHandle({ operation: createPostJsonOperation({}) })).rejects.toThrow(error);
handler = new LoginHandler(accountStore, interactionCompleter);
await expect(handler.canHandle({ operation: createPostJsonOperation({}), oidcInteraction }))
.resolves.toBeUndefined();
});
it('errors on invalid emails.', async(): Promise<void> => {
@ -61,13 +69,13 @@ describe('A LoginHandler', (): void => {
.rejects.toThrow('This server is not an identity provider for this account.');
});
it('returns the correct completion parameters.', async(): Promise<void> => {
it('returns the generated redirect URL.', async(): Promise<void> => {
input.operation = createPostJsonOperation({ email, password: 'password!' });
await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false });
expect(oidcInteraction.save).toHaveBeenCalledTimes(1);
expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }});
});
});

View File

@ -1,6 +1,6 @@
import type { EmailSenderArgs } from '../../../../../src/identity/interaction/util/BaseEmailSender';
import { BaseEmailSender } from '../../../../../src/identity/interaction/util/BaseEmailSender';
import type { EmailArgs } from '../../../../../src/identity/interaction/util/EmailSender';
import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender';
jest.mock('nodemailer');
describe('A BaseEmailSender', (): void => {

View File

@ -1,56 +0,0 @@
import type { Interaction } from '../../../../../src/identity/interaction/InteractionHandler';
import { BaseInteractionCompleter } from '../../../../../src/identity/interaction/util/BaseInteractionCompleter';
jest.useFakeTimers();
describe('A BaseInteractionCompleter', (): void => {
const now = Math.floor(Date.now() / 1000);
const webId = 'http://alice.test.com/#me';
let oidcInteraction: jest.Mocked<Interaction>;
let completer: BaseInteractionCompleter;
beforeEach(async(): Promise<void> => {
oidcInteraction = {
lastSubmission: {},
exp: now + 500,
returnTo: 'http://test.com/redirect',
save: jest.fn(),
} as any;
completer = new BaseInteractionCompleter();
});
it('stores the correct data in the interaction.', async(): Promise<void> => {
await expect(completer.handle({ oidcInteraction, webId, shouldRemember: true }))
.resolves.toBe(oidcInteraction.returnTo);
expect(oidcInteraction.result).toEqual({
login: {
account: webId,
remember: true,
ts: now,
},
consent: {
rejectedScopes: [],
},
});
expect(oidcInteraction.save).toHaveBeenCalledTimes(1);
expect(oidcInteraction.save).toHaveBeenLastCalledWith(500);
});
it('rejects offline access if shouldRemember is false.', async(): Promise<void> => {
await expect(completer.handle({ oidcInteraction, webId, shouldRemember: false }))
.resolves.toBe(oidcInteraction.returnTo);
expect(oidcInteraction.result).toEqual({
login: {
account: webId,
remember: false,
ts: now,
},
consent: {
rejectedScopes: [ 'offline_access' ],
},
});
expect(oidcInteraction.save).toHaveBeenCalledTimes(1);
expect(oidcInteraction.save).toHaveBeenLastCalledWith(500);
});
});