mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
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:
parent
1769b799df
commit
c9ed90aeeb
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
|
||||||
"import": [
|
"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/forgot-password.json",
|
||||||
"files-scs:config/identity/handler/interaction/routes/index.json",
|
"files-scs:config/identity/handler/interaction/routes/index.json",
|
||||||
"files-scs:config/identity/handler/interaction/routes/login.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:IndexRouteHandler" },
|
||||||
{ "@id": "urn:solid-server:auth:password:PromptRouteHandler" },
|
{ "@id": "urn:solid-server:auth:password:PromptRouteHandler" },
|
||||||
{ "@id": "urn:solid-server:auth:password:LoginRouteHandler" },
|
{ "@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:ForgotPasswordRouteHandler" },
|
||||||
{ "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }
|
{ "@id": "urn:solid-server:auth:password:ResetPasswordRouteHandler" }
|
||||||
]
|
]
|
||||||
|
@ -3,18 +3,18 @@
|
|||||||
"@graph": [
|
"@graph": [
|
||||||
{
|
{
|
||||||
"comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
|
"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",
|
"@type":"InteractionRouteHandler",
|
||||||
"route": {
|
"route": {
|
||||||
"@id": "urn:solid-server:auth:password:ExistingLoginRoute",
|
"@id": "urn:solid-server:auth:password:ConsentRoute",
|
||||||
"@type": "RelativePathInteractionRoute",
|
"@type": "RelativePathInteractionRoute",
|
||||||
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
"base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
|
||||||
"relativePath": "/consent/"
|
"relativePath": "/consent/"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"@id": "urn:solid-server:auth:password:ExistingLoginHandler",
|
"@id": "urn:solid-server:auth:password:ConsentHandler",
|
||||||
"@type": "ExistingLoginHandler",
|
"@type": "ConsentHandler",
|
||||||
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
|
"providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
@ -14,8 +14,7 @@
|
|||||||
"source": {
|
"source": {
|
||||||
"@id": "urn:solid-server:auth:password:LoginHandler",
|
"@id": "urn:solid-server:auth:password:LoginHandler",
|
||||||
"@type": "LoginHandler",
|
"@type": "LoginHandler",
|
||||||
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
|
"accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
|
||||||
"interactionCompleter": { "@type": "BaseInteractionCompleter" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"PromptHandler:_promptRoutes_key": "consent",
|
"PromptHandler:_promptRoutes_key": "consent",
|
||||||
"PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }
|
"PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ConsentRoute" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs",
|
"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",
|
"HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs",
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
"args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
||||||
"config": {
|
"config": {
|
||||||
"claims": {
|
"claims": {
|
||||||
"openid": [ "client_id" ],
|
"openid": [ "azp" ],
|
||||||
"webid": [ "webid" ]
|
"webid": [ "webid" ]
|
||||||
},
|
},
|
||||||
"cookies": {
|
"cookies": {
|
||||||
@ -27,22 +27,24 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"claimsParameter": { "enabled": true },
|
"claimsParameter": { "enabled": true },
|
||||||
"devInteractions": { "enabled": false },
|
"devInteractions": { "enabled": false },
|
||||||
"dPoP": { "enabled": true, "ack": "draft-01" },
|
"dPoP": { "enabled": true, "ack": "draft-03" },
|
||||||
"introspection": { "enabled": true },
|
"introspection": { "enabled": true },
|
||||||
"registration": { "enabled": true },
|
"registration": { "enabled": true },
|
||||||
"revocation": { "enabled": true }
|
"revocation": { "enabled": true },
|
||||||
},
|
"userinfo": { "enabled": false }
|
||||||
"formats": {
|
|
||||||
"AccessToken": "jwt"
|
|
||||||
},
|
},
|
||||||
"scopes": [ "openid", "profile", "offline_access", "webid" ],
|
"scopes": [ "openid", "profile", "offline_access", "webid" ],
|
||||||
"subjectTypes": [ "public" ],
|
"subjectTypes": [ "public" ],
|
||||||
"ttl": {
|
"ttl": {
|
||||||
"AccessToken": 3600,
|
"AccessToken": 3600,
|
||||||
"AuthorizationCode": 600,
|
"AuthorizationCode": 600,
|
||||||
|
"BackchannelAuthenticationRequest": 600,
|
||||||
"DeviceCode": 600,
|
"DeviceCode": 600,
|
||||||
|
"Grant": 1209600,
|
||||||
"IdToken": 3600,
|
"IdToken": 3600,
|
||||||
"RefreshToken": 86400
|
"Interaction": 3600,
|
||||||
|
"RefreshToken": 86400,
|
||||||
|
"Session": 1209600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
894
package-lock.json
generated
894
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -88,6 +88,7 @@
|
|||||||
"@types/n3": "^1.10.4",
|
"@types/n3": "^1.10.4",
|
||||||
"@types/node": "^14.18.0",
|
"@types/node": "^14.18.0",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
|
"@types/oidc-provider": "^7.8.1",
|
||||||
"@types/pump": "^1.1.1",
|
"@types/pump": "^1.1.1",
|
||||||
"@types/punycode": "^2.1.0",
|
"@types/punycode": "^2.1.0",
|
||||||
"@types/redis": "^2.8.30",
|
"@types/redis": "^2.8.30",
|
||||||
@ -114,7 +115,7 @@
|
|||||||
"mime-types": "^2.1.34",
|
"mime-types": "^2.1.34",
|
||||||
"n3": "^1.13.0",
|
"n3": "^1.13.0",
|
||||||
"nodemailer": "^6.7.2",
|
"nodemailer": "^6.7.2",
|
||||||
"oidc-provider": "^6.31.1",
|
"oidc-provider": "^7.10.6",
|
||||||
"pump": "^3.0.0",
|
"pump": "^3.0.0",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
"rdf-dereference": "^1.9.0",
|
"rdf-dereference": "^1.9.0",
|
||||||
|
@ -20,8 +20,8 @@ export class OidcHttpHandler extends HttpHandler {
|
|||||||
const provider = await this.providerFactory.getProvider();
|
const provider = await this.providerFactory.getProvider();
|
||||||
this.logger.debug(`Sending request to oidc-provider: ${request.url}`);
|
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.
|
// 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
|
// eslint-disable-next-line @typescript-eslint/await-thenable
|
||||||
await provider.callback(request, response);
|
await provider.callback()(request, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,14 @@
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import type { JWK } from 'jose';
|
import type { JWK } from 'jose';
|
||||||
import { exportJWK, generateKeyPair } from 'jose';
|
import { exportJWK, generateKeyPair } from 'jose';
|
||||||
import type { AnyObject,
|
import type { Account,
|
||||||
|
Adapter,
|
||||||
CanBePromise,
|
CanBePromise,
|
||||||
KoaContextWithOIDC,
|
|
||||||
Configuration,
|
Configuration,
|
||||||
Account,
|
|
||||||
ErrorOut,
|
ErrorOut,
|
||||||
Adapter } from 'oidc-provider';
|
KoaContextWithOIDC,
|
||||||
|
ResourceServer,
|
||||||
|
UnknownObject } from 'oidc-provider';
|
||||||
import { Provider } from 'oidc-provider';
|
import { Provider } from 'oidc-provider';
|
||||||
import type { Operation } from '../../http/Operation';
|
import type { Operation } from '../../http/Operation';
|
||||||
import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
|
import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
|
||||||
@ -75,6 +76,7 @@ export class IdentityProviderFactory implements ProviderFactory {
|
|||||||
private readonly errorHandler!: ErrorHandler;
|
private readonly errorHandler!: ErrorHandler;
|
||||||
private readonly responseWriter!: ResponseWriter;
|
private readonly responseWriter!: ResponseWriter;
|
||||||
|
|
||||||
|
private readonly jwtAlg = 'ES256';
|
||||||
private provider?: Provider;
|
private provider?: Provider;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -139,11 +141,23 @@ export class IdentityProviderFactory implements ProviderFactory {
|
|||||||
keys: await this.generateCookieKeys(),
|
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;
|
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.
|
* The JWKS will be cached so subsequent calls return the same key.
|
||||||
*/
|
*/
|
||||||
private async generateJwks(): Promise<{ keys: JWK[] }> {
|
private async generateJwks(): Promise<{ keys: JWK[] }> {
|
||||||
@ -153,10 +167,10 @@ export class IdentityProviderFactory implements ProviderFactory {
|
|||||||
return jwks;
|
return jwks;
|
||||||
}
|
}
|
||||||
// If they are not, generate and save them
|
// If they are not, generate and save them
|
||||||
const { privateKey } = await generateKeyPair('RS256');
|
const { privateKey } = await generateKeyPair(this.jwtAlg);
|
||||||
const jwk = await exportJWK(privateKey);
|
const jwk = await exportJWK(privateKey);
|
||||||
// Required for Solid authn client
|
// 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,
|
// 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.
|
// 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 ]}`.
|
// 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 {
|
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
|
// Returns the id_token
|
||||||
// See https://solid.github.io/authentication-panel/solid-oidc/#tokens-id
|
// 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> => ({
|
config.findAccount = async(ctx: KoaContextWithOIDC, sub: string): Promise<Account> => ({
|
||||||
accountId: sub,
|
accountId: sub,
|
||||||
claims: async(): Promise<{ sub: string; [key: string]: any }> =>
|
async claims(): Promise<{ sub: string; [key: string]: any }> {
|
||||||
({ sub, webid: sub }),
|
return { sub, webid: sub, azp: ctx.oidc.client?.clientId };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add extra claims in case an AccessToken is being issued.
|
// Add extra claims in case an AccessToken is being issued.
|
||||||
// Specifically this sets the required webid and client_id claims for the access token
|
// 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
|
// See https://solid.github.io/solid-oidc/#resource-access-validation
|
||||||
config.extraAccessTokenClaims = (ctx, token): CanBePromise<AnyObject | void> =>
|
config.extraTokenClaims = (ctx, token): CanBePromise<UnknownObject> =>
|
||||||
this.isAccessToken(token) ?
|
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
|
// When oidc-provider cannot fulfill the authorization request for any of the possible reasons
|
||||||
// (missing user session, requested ACR not fulfilled, prompt requested, ...)
|
// (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.
|
// 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 = {
|
config.interactions = {
|
||||||
url: async(ctx, oidcInteraction): Promise<string> => {
|
url: async(ctx, oidcInteraction): Promise<string> => {
|
||||||
const operation: Operation = {
|
const operation: Operation = {
|
||||||
@ -255,7 +293,7 @@ export class IdentityProviderFactory implements ProviderFactory {
|
|||||||
|
|
||||||
config.routes = {
|
config.routes = {
|
||||||
authorization: this.createRoute('auth'),
|
authorization: this.createRoute('auth'),
|
||||||
check_session: this.createRoute('session/check'),
|
backchannel_authentication: this.createRoute('backchannel'),
|
||||||
code_verification: this.createRoute('device'),
|
code_verification: this.createRoute('device'),
|
||||||
device_authorization: this.createRoute('device/auth'),
|
device_authorization: this.createRoute('device/auth'),
|
||||||
end_session: this.createRoute('session/end'),
|
end_session: this.createRoute('session/end'),
|
||||||
|
@ -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>;
|
|
||||||
}
|
|
111
src/identity/interaction/ConsentHandler.ts
Normal file
111
src/identity/interaction/ConsentHandler.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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) };
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,8 +8,8 @@ import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
|
|||||||
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
|
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
|
||||||
import type { InteractionHandlerInput } from '../../InteractionHandler';
|
import type { InteractionHandlerInput } from '../../InteractionHandler';
|
||||||
import type { InteractionRoute } from '../../routing/InteractionRoute';
|
import type { InteractionRoute } from '../../routing/InteractionRoute';
|
||||||
import type { EmailSender } from '../../util/EmailSender';
|
|
||||||
import type { AccountStore } from '../storage/AccountStore';
|
import type { AccountStore } from '../storage/AccountStore';
|
||||||
|
import type { EmailSender } from '../util/EmailSender';
|
||||||
|
|
||||||
const forgotPasswordView = {
|
const forgotPasswordView = {
|
||||||
required: {
|
required: {
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
|
import type { InteractionResults } from 'oidc-provider';
|
||||||
import type { Operation } from '../../../../http/Operation';
|
import type { Operation } from '../../../../http/Operation';
|
||||||
import { getLoggerFor } from '../../../../logging/LogUtil';
|
import { getLoggerFor } from '../../../../logging/LogUtil';
|
||||||
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
|
import { BadRequestHttpError } from '../../../../util/errors/BadRequestHttpError';
|
||||||
|
import { FoundHttpError } from '../../../../util/errors/FoundHttpError';
|
||||||
import { readJsonStream } from '../../../../util/StreamUtil';
|
import { readJsonStream } from '../../../../util/StreamUtil';
|
||||||
import { CompletingInteractionHandler } from '../../CompletingInteractionHandler';
|
import { BaseInteractionHandler } from '../../BaseInteractionHandler';
|
||||||
import type { InteractionHandlerInput } from '../../InteractionHandler';
|
import type { InteractionHandlerInput } from '../../InteractionHandler';
|
||||||
import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter';
|
|
||||||
import type { AccountStore } from '../storage/AccountStore';
|
import type { AccountStore } from '../storage/AccountStore';
|
||||||
|
|
||||||
const loginView = {
|
const loginView = {
|
||||||
@ -26,19 +27,27 @@ interface LoginInput {
|
|||||||
* Handles the submission of the Login Form and logs the user in.
|
* Handles the submission of the Login Form and logs the user in.
|
||||||
* Will throw a RedirectHttpError on success.
|
* Will throw a RedirectHttpError on success.
|
||||||
*/
|
*/
|
||||||
export class LoginHandler extends CompletingInteractionHandler {
|
export class LoginHandler extends BaseInteractionHandler {
|
||||||
protected readonly logger = getLoggerFor(this);
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
private readonly accountStore: AccountStore;
|
private readonly accountStore: AccountStore;
|
||||||
|
|
||||||
public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
|
public constructor(accountStore: AccountStore) {
|
||||||
super(loginView, interactionCompleter);
|
super(loginView);
|
||||||
this.accountStore = accountStore;
|
this.accountStore = accountStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async getCompletionParameters(input: Required<InteractionHandlerInput>):
|
public async canHandle(input: InteractionHandlerInput): Promise<void> {
|
||||||
Promise<InteractionCompleterInput> {
|
await super.canHandle(input);
|
||||||
const { operation, oidcInteraction } = 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);
|
const { email, password, remember } = await this.parseInput(operation);
|
||||||
// Try to log in, will error if email/password combination is invalid
|
// Try to log in, will error if email/password combination is invalid
|
||||||
const webId = await this.accountStore.authenticate(email, password);
|
const webId = await this.accountStore.authenticate(email, password);
|
||||||
@ -49,7 +58,15 @@ export class LoginHandler extends CompletingInteractionHandler {
|
|||||||
}
|
}
|
||||||
this.logger.debug(`Logging in user ${email}`);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../../../util/handlers/AsyncHandler';
|
||||||
|
|
||||||
export interface EmailArgs {
|
export interface EmailArgs {
|
||||||
recipient: string;
|
recipient: string;
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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> {}
|
|
11
src/index.ts
11
src/index.ts
@ -139,6 +139,8 @@ export * from './identity/interaction/email-password/storage/AccountStore';
|
|||||||
export * from './identity/interaction/email-password/storage/BaseAccountStore';
|
export * from './identity/interaction/email-password/storage/BaseAccountStore';
|
||||||
|
|
||||||
// Identity/Interaction/Email-Password/Util
|
// 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';
|
export * from './identity/interaction/email-password/util/RegistrationManager';
|
||||||
|
|
||||||
// Identity/Interaction/Email-Password
|
// 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/InteractionRouteHandler';
|
||||||
export * from './identity/interaction/routing/RelativePathInteractionRoute';
|
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
|
// Identity/Interaction
|
||||||
export * from './identity/interaction/BaseInteractionHandler';
|
export * from './identity/interaction/BaseInteractionHandler';
|
||||||
export * from './identity/interaction/CompletingInteractionHandler';
|
export * from './identity/interaction/ConsentHandler';
|
||||||
export * from './identity/interaction/ExistingLoginHandler';
|
|
||||||
export * from './identity/interaction/ControlHandler';
|
export * from './identity/interaction/ControlHandler';
|
||||||
export * from './identity/interaction/FixedInteractionHandler';
|
export * from './identity/interaction/FixedInteractionHandler';
|
||||||
export * from './identity/interaction/HtmlViewHandler';
|
export * from './identity/interaction/HtmlViewHandler';
|
||||||
|
@ -138,10 +138,11 @@ describe('A Solid server with IDP', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('initializes the session and logs in.', async(): Promise<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);
|
const res = await state.fetchIdp(url);
|
||||||
expect(res.status).toBe(200);
|
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);
|
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> => {
|
it('can log in again.', async(): Promise<void> => {
|
||||||
const url = await state.startSession();
|
const url = await state.startSession();
|
||||||
|
|
||||||
let res = await state.fetchIdp(url);
|
const res = await state.fetchIdp(url);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
// Will receive confirm screen here instead of login screen
|
// Will receive confirm screen here instead of login screen
|
||||||
res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
|
await state.consent(url);
|
||||||
const json = await res.json();
|
|
||||||
const nextUrl = json.location;
|
|
||||||
expect(typeof nextUrl).toBe('string');
|
|
||||||
|
|
||||||
await state.handleLoginRedirect(nextUrl);
|
|
||||||
expect(state.session.info?.webId).toBe(webId);
|
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> => {
|
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);
|
const res = await state.fetchIdp(url);
|
||||||
expect(res.status).toBe(200);
|
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);
|
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> => {
|
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);
|
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> => {
|
it('initializes the session and logs in.', async(): Promise<void> => {
|
||||||
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
|
state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
|
||||||
const url = await state.startSession();
|
let url = await state.startSession();
|
||||||
const res = await state.fetchIdp(url);
|
const res = await state.fetchIdp(url);
|
||||||
expect(res.status).toBe(200);
|
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);
|
expect(state.session.info?.webId).toBe(newWebId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ export class IdentityTestState {
|
|||||||
|
|
||||||
// Need to catch the redirect so we can copy the cookies
|
// Need to catch the redirect so we can copy the cookies
|
||||||
let res = await this.fetchIdp(nextUrl);
|
let res = await this.fetchIdp(nextUrl);
|
||||||
expect(res.status).toBe(302);
|
expect(res.status).toBe(303);
|
||||||
nextUrl = res.headers.get('location')!;
|
nextUrl = res.headers.get('location')!;
|
||||||
|
|
||||||
// Handle redirect
|
// Handle redirect
|
||||||
@ -109,22 +109,26 @@ export class IdentityTestState {
|
|||||||
* Logs in by sending the corresponding email and password to the given form action.
|
* Logs in by sending the corresponding email and password to the given form action.
|
||||||
* The URL should be extracted from the login page.
|
* 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 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);
|
expect(res.status).toBe(200);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
const nextUrl = json.location;
|
res = await this.fetchIdp(json.location);
|
||||||
|
expect(res.status).toBe(303);
|
||||||
return this.handleLoginRedirect(nextUrl);
|
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> {
|
public async consent(url: string): Promise<void> {
|
||||||
const res = await this.fetchIdp(url);
|
let res = await this.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
|
||||||
expect(res.status).toBe(302);
|
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')!;
|
const mockUrl = res.headers.get('location')!;
|
||||||
expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy();
|
expect(mockUrl.startsWith(this.redirectUrl)).toBeTruthy();
|
||||||
|
|
||||||
|
@ -94,10 +94,11 @@ describe('A server with restricted IDP access', (): void => {
|
|||||||
it('can still access registration with the correct credentials.', async(): Promise<void> => {
|
it('can still access registration with the correct credentials.', async(): Promise<void> => {
|
||||||
// Logging into session
|
// Logging into session
|
||||||
const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl);
|
const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl);
|
||||||
const url = await state.startSession();
|
let url = await state.startSession();
|
||||||
let res = await state.fetchIdp(url);
|
let res = await state.fetchIdp(url);
|
||||||
expect(res.status).toBe(200);
|
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);
|
expect(state.session.info?.webId).toBe(webId);
|
||||||
|
|
||||||
// Registration still works for this WebID
|
// Registration still works for this WebID
|
||||||
|
@ -13,7 +13,7 @@ describe('An OidcHttpHandler', (): void => {
|
|||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
provider = {
|
provider = {
|
||||||
callback: jest.fn(),
|
callback: jest.fn().mockReturnValue(jest.fn()),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
providerFactory = {
|
providerFactory = {
|
||||||
@ -26,6 +26,7 @@ describe('An OidcHttpHandler', (): void => {
|
|||||||
it('sends all requests to the OIDC library.', async(): Promise<void> => {
|
it('sends all requests to the OIDC library.', async(): Promise<void> => {
|
||||||
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
await expect(handler.handle({ request, response })).resolves.toBeUndefined();
|
||||||
expect(provider.callback).toHaveBeenCalledTimes(1);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,7 +16,7 @@ jest.mock('oidc-provider', (): any => ({
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
authorization: '/foo/oidc/auth',
|
authorization: '/foo/oidc/auth',
|
||||||
check_session: '/foo/oidc/session/check',
|
backchannel_authentication: '/foo/oidc/backchannel',
|
||||||
code_verification: '/foo/oidc/device',
|
code_verification: '/foo/oidc/device',
|
||||||
device_authorization: '/foo/oidc/device/auth',
|
device_authorization: '/foo/oidc/device/auth',
|
||||||
end_session: '/foo/oidc/session/end',
|
end_session: '/foo/oidc/session/end',
|
||||||
@ -100,23 +100,32 @@ describe('An IdentityProviderFactory', (): void => {
|
|||||||
expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
|
expect(adapterFactory.createStorageAdapter).toHaveBeenLastCalledWith('test!');
|
||||||
|
|
||||||
expect(config.cookies?.keys).toEqual([ expect.any(String) ]);
|
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.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);
|
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);
|
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 });
|
await expect((findResult?.claims as any)()).resolves.toEqual({ sub: webId, webid: webId });
|
||||||
|
|
||||||
expect((config.extraAccessTokenClaims as any)({}, {})).toEqual({});
|
expect((config.extraTokenClaims as any)({}, {})).toEqual({});
|
||||||
expect((config.extraAccessTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
|
expect((config.extraTokenClaims as any)({}, { kind: 'AccessToken', accountId: webId, clientId: 'clientId' }))
|
||||||
.toEqual({
|
.toEqual({ webid: webId });
|
||||||
webid: webId,
|
|
||||||
client_id: 'clientId',
|
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
|
// Test the renderError function
|
||||||
const response = { } as HttpResponse;
|
const response = { } as HttpResponse;
|
||||||
|
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
142
test/unit/identity/interaction/ConsentHandler.test.ts
Normal file
142
test/unit/identity/interaction/ConsentHandler.test.ts
Normal 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' ]);
|
||||||
|
});
|
||||||
|
});
|
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
|
@ -3,8 +3,8 @@ import {
|
|||||||
ForgotPasswordHandler,
|
ForgotPasswordHandler,
|
||||||
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
|
} from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
|
||||||
import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
|
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 { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute';
|
||||||
import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
|
|
||||||
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
|
import { readJsonStream } from '../../../../../../src/util/StreamUtil';
|
||||||
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
|
import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
|
||||||
import { createPostJsonOperation } from './Util';
|
import { createPostJsonOperation } from './Util';
|
||||||
|
@ -4,22 +4,23 @@ import type {
|
|||||||
Interaction,
|
Interaction,
|
||||||
InteractionHandlerInput,
|
InteractionHandlerInput,
|
||||||
} from '../../../../../../src/identity/interaction/InteractionHandler';
|
} from '../../../../../../src/identity/interaction/InteractionHandler';
|
||||||
import type {
|
|
||||||
InteractionCompleter,
|
|
||||||
} from '../../../../../../src/identity/interaction/util/InteractionCompleter';
|
|
||||||
import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
|
import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
|
||||||
import { createPostJsonOperation } from './Util';
|
import { createPostJsonOperation } from './Util';
|
||||||
|
|
||||||
describe('A LoginHandler', (): void => {
|
describe('A LoginHandler', (): void => {
|
||||||
const webId = 'http://alice.test.com/card#me';
|
const webId = 'http://alice.test.com/card#me';
|
||||||
const email = 'alice@test.email';
|
const email = 'alice@test.email';
|
||||||
const oidcInteraction: Interaction = {} as any;
|
let oidcInteraction: jest.Mocked<Interaction>;
|
||||||
let input: Required<InteractionHandlerInput>;
|
let input: Required<InteractionHandlerInput>;
|
||||||
let accountStore: jest.Mocked<AccountStore>;
|
let accountStore: jest.Mocked<AccountStore>;
|
||||||
let interactionCompleter: jest.Mocked<InteractionCompleter>;
|
|
||||||
let handler: LoginHandler;
|
let handler: LoginHandler;
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
|
oidcInteraction = {
|
||||||
|
exp: 123456,
|
||||||
|
save: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
input = { oidcInteraction } as any;
|
input = { oidcInteraction } as any;
|
||||||
|
|
||||||
accountStore = {
|
accountStore = {
|
||||||
@ -27,11 +28,18 @@ describe('A LoginHandler', (): void => {
|
|||||||
getSettings: jest.fn().mockResolvedValue({ useIdp: true }),
|
getSettings: jest.fn().mockResolvedValue({ useIdp: true }),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
interactionCompleter = {
|
handler = new LoginHandler(accountStore);
|
||||||
handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
|
});
|
||||||
} as any;
|
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> => {
|
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.');
|
.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!' });
|
input.operation = createPostJsonOperation({ email, password: 'password!' });
|
||||||
await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
|
await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
|
||||||
|
|
||||||
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
|
expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
|
||||||
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
|
expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
|
||||||
expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
|
expect(oidcInteraction.save).toHaveBeenCalledTimes(1);
|
||||||
expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false });
|
expect(oidcInteraction.result).toEqual({ login: { accountId: webId, remember: false }});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { EmailSenderArgs } from '../../../../../src/identity/interaction/util/BaseEmailSender';
|
import type { EmailSenderArgs } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
|
||||||
import { BaseEmailSender } from '../../../../../src/identity/interaction/util/BaseEmailSender';
|
import { BaseEmailSender } from '../../../../../../src/identity/interaction/email-password/util/BaseEmailSender';
|
||||||
import type { EmailArgs } from '../../../../../src/identity/interaction/util/EmailSender';
|
import type { EmailArgs } from '../../../../../../src/identity/interaction/email-password/util/EmailSender';
|
||||||
jest.mock('nodemailer');
|
jest.mock('nodemailer');
|
||||||
|
|
||||||
describe('A BaseEmailSender', (): void => {
|
describe('A BaseEmailSender', (): void => {
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
Loading…
x
Reference in New Issue
Block a user