diff --git a/package-lock.json b/package-lock.json index da7b84ae..6ed299f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30262,7 +30262,7 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 24a5275e..2a96cc0f 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -17,6 +17,7 @@ const VERIFY_CATEGORIES = [ 'Getting Started', 'eIDs', 'Guides & Tools', + 'Extensions', 'Integrations', 'Reference', ]; diff --git a/src/components/TabGroup.tsx b/src/components/TabGroup.tsx new file mode 100644 index 00000000..702dd77a --- /dev/null +++ b/src/components/TabGroup.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import cx from 'classnames'; + +export type TabListProps = { + children: React.ReactNode; +}; + +export type ControlledTabGroupProps = { + tabs: { + header: React.ReactNode; + body: () => React.ReactNode; + }[]; +}; + +export function TabGroup(props: TabListProps) { + return ( + + ); +} + +TabGroup.NavItem = function ( + props: { children: React.ReactNode, active: boolean } & React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + >, +) { + return ( + + ); +}; + +export function ControlledTabGroup(props: ControlledTabGroupProps) { + const [activeTabIndex, setActiveTabIndex] = useState(0); + + const Body = props.tabs[activeTabIndex].body(); + + return ( +
+ + {Object.values(props.tabs).map((tab, i) => ( + setActiveTabIndex(i)}>{tab.header} + ))} + + {Body} +
+ ); +} diff --git a/src/pages/verify/extensions/ciam-interop.mdx b/src/pages/verify/extensions/ciam-interop.mdx new file mode 100644 index 00000000..9511439b --- /dev/null +++ b/src/pages/verify/extensions/ciam-interop.mdx @@ -0,0 +1,45 @@ +--- +product: verify +category: Extensions +sort: 1 +title: CIAM Interop +subtitle: Provides Interoperability with CIAM systems, such as Microsoft Entra External ID. +--- + +The CIAM Interop extension provides interoperability with CIAM systems past what Idura Verify supports out-of-the-box. + +# Synthesized Email Claims + +Some CIAM systems require that OIDC providers always provide an `email` claim. Usually, [eID providers](../../e-ids/) do not provide emails. In order to enable the use of Idura Verify as a provider for these systems, the `dummy_email_domain` option can be used to synthesize the `email` claim where it does not exist. + +If set, the extension will inspect the token claims and if no `email` is present in the input, add one with the value `{user.sub}@{dummy_email_domain}`. The user sub is a unique ID per user which is consistent between multiple logins on the same eID. + +# Verified Emails + +While some systems only require that the `email` is set, some also require the `email_verified` claim to be `true`. If the `force_email_verified` flag is enabled, the extension will set the `email_verified` claim in addition to the `email` claim. + +**Dummy Email Domains must be enabled**. The extension will not set the `email_verified` claim, if the `email` claim is already present in the input JWT. + +# Unnesting Nested Claims + +Some CIAM providers do not support nested (object) claims in JWTs. The `unnesting_namespace` option can be used to flatten the incoming JWT. + +If a `unnesting_namespace` is set, all nested claims (`a.b.c`) will be converted to namespaced claims (`{unnesting_namespace}/a/b/c`). Note: The unnesting namespace **must** begin with `https://` + +For example, the OpenID standard address claim: + +```json +{ + "address": { + "street_address": "value" + } +} +``` + +will be flattened to: + +```json +{ + "{unnesting_namespace}/address/street_address": "value" +} +``` diff --git a/src/pages/verify/extensions/files/post-auth-webhook-request.1.0.schema.json b/src/pages/verify/extensions/files/post-auth-webhook-request.1.0.schema.json new file mode 100644 index 00000000..793807f0 --- /dev/null +++ b/src/pages/verify/extensions/files/post-auth-webhook-request.1.0.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://criipto.com/extensions/webhook/post-auth-request.1.0.schema.json", + "title": "PostAuthWebhookExtension10Request", + "type": "object", + "required": ["method", "headers", "body"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["POST"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "required": ["Authorization", "Content-Type"], + "properties": { + "Authorization": { "type": "string" }, + "Content-Type": { "type": "string", "enum": ["application/json"] } + } + }, + "body": { + "type": "object", + "additionalProperties": false, + "required": ["event", "conversationId", "environment", "user", "resumeUrl"], + "properties": { + "event": { + "type": "string", + "enum": ["post-auth-event-1.0"] + }, + "conversationId": { + "type": "string", + "description": "Random ID identifying this particular login session. Should be used as a session key to persist state between post-auth and post-auth-resume." + }, + "environment": { + "type": "string", + "enum": ["test", "production"] + }, + "user": { + "type": "object", + "additionalProperties": true, + "required": ["sub"], + "properties": { + "sub": { "type": "string" } + } + }, + "resumeUrl": { + "type": "string", + "description": "If the webhook responds with a redirect, the login process is effectively paused until the user is directed back to this URL, at which point the webhook will receive a post-auth-resume event." + } + } + } + } +} diff --git a/src/pages/verify/extensions/files/post-auth-webhook-response.1.0.schema.json b/src/pages/verify/extensions/files/post-auth-webhook-response.1.0.schema.json new file mode 100644 index 00000000..8610b6ee --- /dev/null +++ b/src/pages/verify/extensions/files/post-auth-webhook-response.1.0.schema.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://criipto.com/extensions/webhook/post-auth-response.1.0.schema.json", + "title": "PostAuthWebhookExtension10Response", + "anyOf": [ + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [200] + }, + "body": { + "$ref": "#/$defs/okBody" + } + } + }, + { + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [204] + } + } + }, + { + "type": "object", + "required": ["status", "headers"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [303] + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "required": ["Location"], + "properties": { + "Location": { "type": "string" } + } + } + } + }, + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [403] + }, + "body": { + "type": "object", + "required": ["error"], + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + }, + "error_uri": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [400, 401, 402, 403, 500, 501, 502, 503, 504] + }, + "body": { + "$ref": "#/$defs/errorBody" + } + } + } + ], + "$defs": { + "claimsOperations": { + "type": "object", + "additionalProperties": false, + "properties": { + "$set": { + "type": "object", + "description": "Map of claims to be set on the resulting token." + }, + "$remove": { + "type": "array", + "description": "Claim keys which should be removed from the token.", + "items": { + "type": "string" + } + } + } + }, + "okBody": { + "anyOf": [ + { + "type": "object", + "required": ["claims"], + "additionalProperties": false, + "deprecated": true, + "description": "Deprecated: Prefer using 'claimsOperations.$set'", + "properties": { + "claims": { + "type": "object", + "description": "Claims to be set on the resulting token. Equivalent to 'claimsOperations.$set'" + } + } + }, + { + "type": "object", + "required": ["claimsOperations"], + "additionalProperties": false, + "properties": { + "claimsOperations": { + "$ref": "#/$defs/claimsOperations" + } + } + } + ] + }, + "errorBody": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { "type": "string" } + } + } + } +} diff --git a/src/pages/verify/extensions/files/post-auth-webhook-resume-request.1.0.schema.json b/src/pages/verify/extensions/files/post-auth-webhook-resume-request.1.0.schema.json new file mode 100644 index 00000000..0932043b --- /dev/null +++ b/src/pages/verify/extensions/files/post-auth-webhook-resume-request.1.0.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://criipto.com/extensions/webhook/post-auth-resume-request.1.0.schema.json", + "title": "PostAuthResumeWebhookExtension10Request", + "type": "object", + "required": ["method", "headers", "body"], + "additionalProperties": false, + "properties": { + "method": { + "type": "string", + "enum": ["POST"] + }, + "headers": { + "type": "object", + "additionalProperties": false, + "required": ["Authorization", "Content-Type"], + "properties": { + "Authorization": { "type": "string" }, + "Content-Type": { "type": "string", "enum": ["application/json"] } + } + }, + "body": { + "type": "object", + "additionalProperties": false, + "required": ["event", "conversationId", "environment", "resumeRequest"], + "properties": { + "event": { + "type": "string", + "enum": ["post-auth-resume-event-1.0"] + }, + "conversationId": { + "type": "string", + "description": "Random ID identifying this particular login session. Should be used as a session key to persist state between post-auth and post-auth-resume." + }, + "environment": { + "type": "string", + "enum": ["test", "production"] + }, + "resumeRequest": { + "type": "object", + "additionalProperties": false, + "required": ["url"], + "properties": { + "url": { "type": "string" } + } + } + } + } + } +} diff --git a/src/pages/verify/extensions/files/post-auth-webhook-resume-response.1.0.schema.json b/src/pages/verify/extensions/files/post-auth-webhook-resume-response.1.0.schema.json new file mode 100644 index 00000000..d67110d6 --- /dev/null +++ b/src/pages/verify/extensions/files/post-auth-webhook-resume-response.1.0.schema.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://criipto.com/extensions/webhook/post-auth-resume-response.1.0.schema.json", + "title": "PostAuthResumeWebhookExtension10Response", + "anyOf": [ + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [200] + }, + "body": { + "$ref": "#/$defs/okBody" + } + } + }, + { + "type": "object", + "required": ["status"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [204] + } + } + }, + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [403] + }, + "body": { + "type": "object", + "required": ["error"], + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "error_description": { + "type": "string" + }, + "error_uri": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "required": ["status", "body"], + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "enum": [400, 401, 402, 403, 500, 501, 502, 503, 504] + }, + "body": { + "$ref": "#/$defs/errorBody" + } + } + } + ], + "$defs": { + "claimsOperations": { + "type": "object", + "additionalProperties": false, + "properties": { + "$set": { + "type": "object", + "description": "Map of claims to be set on the resulting token." + }, + "$remove": { + "type": "array", + "description": "Claim keys which should be removed from the token.", + "items": { + "type": "string" + } + } + } + }, + "okBody": { + "anyOf": [ + { + "type": "object", + "required": ["claims"], + "additionalProperties": false, + "deprecated": true, + "description": "Deprecated: Prefer using 'claimsOperations.$set'", + "properties": { + "claims": { + "type": "object", + "description": "Claims to be set on the resulting token. Equivalent to 'claimsOperations.$set'" + } + } + }, + { + "type": "object", + "required": ["claimsOperations"], + "additionalProperties": false, + "properties": { + "claimsOperations": { + "$ref": "#/$defs/claimsOperations" + } + } + } + ] + }, + "errorBody": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { "type": "string" } + } + } + } +} diff --git a/src/pages/verify/extensions/index.mdx b/src/pages/verify/extensions/index.mdx new file mode 100644 index 00000000..b16d82c6 --- /dev/null +++ b/src/pages/verify/extensions/index.mdx @@ -0,0 +1,55 @@ +--- +product: verify +category: Extensions +sort: 0 +title: Extensions in Idura Verify +subtitle: Customise your login flows with Idura Verify Extensions +--- + +import { isIndexPage } from '../../../utils'; + +import { graphql as gatsbyGraphql, Link } from 'gatsby'; +export const pageQuery = gatsbyGraphql` + query Extensions { + pages: allMdx( + filter: { + frontmatter: { + product: { eq: "verify" } + category: { eq: "Extensions" } + } + } + sort: {frontmatter: {sort: ASC}} + ) { + edges { + node { + __typename + id + frontmatter { + title + subtitle + category + } + fields { + slug + } + internal { + contentFilePath + } + } + } + } + } +`; + + diff --git a/src/pages/verify/extensions/webhook-post-auth-schemas.tsx b/src/pages/verify/extensions/webhook-post-auth-schemas.tsx new file mode 100644 index 00000000..997ea40f --- /dev/null +++ b/src/pages/verify/extensions/webhook-post-auth-schemas.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import postAuthRequest from './files/post-auth-webhook-request.1.0.schema.json'; +import postAuthResumeRequest from './files/post-auth-webhook-resume-request.1.0.schema.json'; +import postAuthResponse from './files/post-auth-webhook-response.1.0.schema.json'; +import postAuthResumeResponse from './files/post-auth-webhook-resume-response.1.0.schema.json'; + +import { ControlledTabGroup } from '../../../components/TabGroup'; +import { Code } from '../../../components/MdxProvider'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCopy } from '@fortawesome/free-solid-svg-icons'; + +export default function WebhookPostAuthSchemas() { + const Schemas = { + 'post-auth-request': postAuthRequest, + 'post-auth-resume-request': postAuthResumeRequest, + 'post-auth-response': postAuthResponse, + 'post-auth-resume-response': postAuthResumeResponse, + }; + + const tabs = Object.entries(Schemas).map(([k, v]) => ({ + header: k, + body: () => ( +
+ navigator.clipboard.writeText(JSON.stringify(v, undefined, 2))} + icon={faCopy} + /> + + {JSON.stringify(v, undefined, 2)} + +
+ ), + })); + + return ; +} diff --git a/src/pages/verify/extensions/webhook-post-auth.mdx b/src/pages/verify/extensions/webhook-post-auth.mdx new file mode 100644 index 00000000..5b84a94a --- /dev/null +++ b/src/pages/verify/extensions/webhook-post-auth.mdx @@ -0,0 +1,153 @@ +--- +product: verify +category: Extensions +sort: 0 +title: Webhook Post-Auth +subtitle: Augment your Login Flows with custom Webhooks to add/remove claims, implement additional security checks or redirect users. +--- + +Webhooks allow you to hook into the Idura Verify login lifecycle directly to implement custom authentication logic and integrate with other systems. Webhooks can be implemented in any programming language of your choice. + +Example use cases include: + +- Fetching additional claims from a CX or CIAM system and adding them to the Idura Verify token +- Redirecting users to accept terms on your website before continuing the login flow + +# Configuration + +- `url`: URL where your Webhook is hosted. Must be HTTPS. Subpaths are supported. + ex. `https://your.domain/webhook/invoke` +- `claim_whitelist`: List of OpenID Connect Standard claims your webhook can set. By default, webhooks are disallowed from setting non-namespaced claims for security reasons. + ex. `['address', 'email', 'phone_number']` + +# Requests + +Your webhook will be invoked with an `application/json` body and an `Authorization: Bearer ` header (see Security). + +There is two types of events your webhook will receive: + +- `post-auth`: A post-auth event is sent when a user has successfully completed a login with a eID provider. In response to a post-auth event you can add or remove claims, abort the login process or redirect the user to another webpage. +- `post-auth-resume`: A post-auth-resume event is sent when a user was redirected in response to a 'post-auth' event and has been redirected back to Idura Verify. In response to a post-auth event you can add or remove claims or abort the login process. +- Block specific logins based on custom criteria + +## Common Properties + +- `event`: Identifying the type of event (`post-auth-event-1.0` or `post-auth-resume-event-1.0`) +- `conversationId`: Unique ID per-login. Can be used as a key to store session state between `post-auth` and `post-auth-resume`. +- `environment`: Indicates the tenant environment. Either `test` or `production`. + +## Post-Auth Example + +```json +{ + "event": "post-auth-event-1.0", + "conversationId": "e926e5da4c8d428e8c4f36d88060459e", + "environment": "test", + "user": { + "identityscheme": "sebankid", + "sub": "{ba8568cb-e9f4-4d1c-a9a5-814462641bdc}", + "...": "eID specific" + }, + "resumeUrl": "https://extensions.criipto.com/extension/resumePostAuth?..." +} +``` + +The `user` field will always contain a `sub` entry uniquely identifying a user. Additional properties are eID-specific. You can find examples of the JWT token formats on the eID provider pages. + +The `resumeUrl` field is used when redirecting the user to another webpage or service. Once the user is done interacting with that page, they should be redirected to this url to trigger the `post-auth-resume` event and continue the login flow. + +## Post-Auth-Resume Example + +Your webhook will be invoked with a post-auth-resume event once the user has been redirected to the `resumeUrl` of the initial `post-auth` request. + +```json +{ + "event": "post-auth-resume-event-1.0", + "conversationId": "e926e5da4c8d428e8c4f36d88060459e", + "environment": "test", + "resumeRequest": { + "url": "..." + } +} +``` + +To persist state between the `post-auth` and `post-auth-resume` events, `conversationId` should be used as a session identifier. + +# Responses + +## No-op response + +The webhook should respond with HTTP status code `204` if it wishes to make no modifications to the login flow. + +## Adding/Removing Claims + +The webhook should respond with HTTP status code `200` if it wishes to add or remove claims to the issued login token. The response body should be `application/json` encoded and contain a `claimsOperations` key containg a key-value map. + +Below is a non-normative example response: + +```json +{ + "claimsOperations": { + "$set": { + "https://customer.com/namespace/key": "value", + "https://customer.com/namespace/complex": { + "key": "value" + } + }, + "$remove": { + "key": "value" + } + } +} +``` + +## Redirect response + +The webhook should respond with HTTP status code `303` and a `Location` header indicating where the user should be redirected to. After the redirect, the login process is effectively paused. To resume the process the user must be redirected back to `resumeUrl`, at which point your webhook will be invoked again with a `post-auth-resume` event. + +# Security + +All webhook invocations by Idura will include an `Authorization: Bearer ` header. The token must be validated on all requests to ensure the authenticity of the HTTP request. + +## Example token format + +```json +{ + "jti": "90fb519d-6d64-4646-a02c-39b4833d61b2", + "iss": "https://extensions.criipto.com/service", + "sub": "e1369c3702d344e38e2cbc3fcb947e01", + "aud": "ext_test_webhook_postauth_03028258" +} +``` + +## Token validation + +- Perform standard token validation against JWKS from `https://extensions.criipto.com/service/.well-known/jwks` +- Validate `nbf` and `exp` as usual +- Validate that `iss` is `https://extensions.criipto.com/service` +- Validate that `sub` matches your tenant id +- Validate that `aud` matches your extension id + +## Replay attacks + +The bearer token contains a `jti` that must be checked against a store to guard against replay attacks. If `jti` reuse is detected the webhook request **MUST** be rejected. + +## Shared responsibility model + +**Responsibilities of Idura** + +- Idura secures the boundary between Idura Verify, the extension executor service and the webhook executor service +- Idura ensures that webhook response bodies are never logged +- Idura ensures that webhooks are always invoked with a fresh bearer token + +**Responsibilities of customer** + +- Customer must perform token validation on each request +- Customer must perform replay attack detection on each request +- Customer must ensure that no request payloads are logged + +# Schemas + +import WebhookPostAuthSchemas from './webhook-post-auth-schemas'; + + diff --git a/tailwind.config.js b/tailwind.config.js index c6aa64e9..6fa28e05 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ const colors = require('tailwindcss/colors'); module.exports = { - content: ['./src/**/*.{js,jsx,ts,tsx}'], + content: ['./src/**/*.{js,jsx,ts,tsx,mdx}'], theme: { colors: { ...colors,