├── .nvmrc ├── .gitattributes ├── pnpm-workspace.yaml ├── e2e ├── .env ├── tsconfig.json ├── https │ └── aspnetapp.pfx ├── config │ ├── server-options.json │ ├── identity-resources.json │ ├── api-resources.yaml │ ├── users.yaml │ └── clients.json ├── types │ ├── claim.ts │ ├── api-resource.ts │ ├── user.ts │ ├── index.ts │ └── client.ts ├── docker-compose.override.yml ├── utils │ ├── jwt-payload-serializer.js │ └── jwt-serializer.js ├── helpers │ ├── index.ts │ ├── user-info-endpoint.ts │ ├── grants.ts │ ├── token-endpoint.ts │ ├── authorization-endpoint.ts │ ├── endpoints.ts │ └── introspect-endpoint.ts ├── tests │ ├── __snapshots__ │ │ └── base-path.spec.ts.snap │ ├── flows │ │ ├── __snapshots__ │ │ │ ├── client-credentials-flow.spec.ts.snap │ │ │ ├── password-flow.spec.ts.snap │ │ │ ├── authorization-code.e2e-spec.ts.snap │ │ │ ├── authorization-code-pkce.e2e-spec.ts.snap │ │ │ └── implicit-flow.e2e-spec.ts.snap │ │ ├── client-credentials-flow.spec.ts │ │ ├── password-flow.spec.ts │ │ ├── authorization-code.e2e-spec.ts │ │ ├── authorization-code-pkce.e2e-spec.ts │ │ └── implicit-flow.e2e-spec.ts │ ├── custom-endpoints │ │ ├── __snapshots__ │ │ │ └── user-management.spec.ts.snap │ │ └── user-management.spec.ts │ └── base-path.spec.ts ├── package.json ├── jest.config.ts └── docker-compose.yml ├── .prettierignore ├── .commitlintrc.yml ├── .prettierrc ├── .husky ├── commit-msg └── pre-commit ├── src ├── .dockerignore ├── Controllers │ ├── HealthController.cs │ └── UserController.cs ├── getui.sh ├── Helpers │ ├── MergeHelper.cs │ ├── OptionsHelper.cs │ └── AspNetServicesHelper.cs ├── Services │ ├── CorsPolicyService.cs │ └── ProfileService.cs ├── YamlConverters │ ├── SecretYamlConverter.cs │ └── ClaimYamlConverter.cs ├── Validation │ └── RedirectUriValidator.cs ├── JsonConverters │ └── ClaimJsonConverter.cs ├── Middlewares │ └── BasePathMiddleware.cs ├── Dockerfile ├── OpenIdConnectServerMock.csproj ├── tempkey.rsa ├── Program.cs └── Config.cs ├── .lintstagedrc ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── Tiltfile ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── eslint.config.js ├── package.json ├── .github └── workflows │ ├── pr.yaml │ └── tag.yaml ├── README.md └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - e2e 3 | -------------------------------------------------------------------------------- /e2e/.env: -------------------------------------------------------------------------------- 1 | OIDC_BASE_URL=https://localhost:8443 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - '@commitlint/config-conventional' 3 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /e2e/https/aspnetapp.pfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Soluto/oidc-server-mock/HEAD/e2e/https/aspnetapp.pfx -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /e2e/config/server-options.json: -------------------------------------------------------------------------------- 1 | { 2 | "AccessTokenJwtType": "JWT", 3 | "Discovery": { 4 | "ShowKeySet": true 5 | } 6 | } -------------------------------------------------------------------------------- /e2e/types/claim.ts: -------------------------------------------------------------------------------- 1 | export default interface Claim { 2 | Type: string; 3 | Value: string; 4 | ValueType?: string; 5 | } 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | npx pretty-quick --staged 6 | -------------------------------------------------------------------------------- /e2e/docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | services: 2 | oidc-server-mock: 3 | image: ghcr.io/soluto/oidc-server-mock:${IMAGE_TAG} 4 | -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | 4 | Pages 5 | wwwroot 6 | 7 | keys/ 8 | tempkey.jwk 9 | 10 | Dockerfile 11 | .dockerignore 12 | -------------------------------------------------------------------------------- /e2e/types/api-resource.ts: -------------------------------------------------------------------------------- 1 | export default interface ApiResource { 2 | Name: string; 3 | Scopes?: string[]; 4 | ApiSecrets?: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /e2e/config/identity-resources.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Name": "some-custom-identity", 4 | "ClaimTypes": ["some-custom-identity-user-claim"] 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /e2e/types/user.ts: -------------------------------------------------------------------------------- 1 | import type Claim from './claim'; 2 | 3 | export default interface User { 4 | SubjectId: string; 5 | Username: string; 6 | Password: string; 7 | Claims?: Claim[]; 8 | } 9 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | "**/*.js": 2 | - prettier --write 3 | "**/*.ts": 4 | - bash -c "tsc --noEmit" 5 | - eslint --fix 6 | - prettier --write 7 | "**/*.{json,yaml,yml,md}": 8 | - prettier --write 9 | 10 | -------------------------------------------------------------------------------- /e2e/config/api-resources.yaml: -------------------------------------------------------------------------------- 1 | - Name: some-app 2 | Scopes: 3 | - some-app-scope-1 4 | - some-app-scope-2 5 | ApiSecrets: 6 | - some-app-secret-1 7 | UserClaims: 8 | - some-app-user-custom-claim 9 | -------------------------------------------------------------------------------- /e2e/types/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ApiResource } from './api-resource'; 2 | export { default as Claim } from './claim'; 3 | export { default as Client } from './client'; 4 | export { default as User } from './user'; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # TypeScript project 2 | node_modules 3 | dist 4 | 5 | # .Net project 6 | bin 7 | obj 8 | 9 | # Docker 10 | .docker 11 | 12 | # UI 13 | src/Pages 14 | src/wwwroot 15 | 16 | # Runtime data 17 | keys 18 | tempkey.jwk 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.{ts,js,json,yaml,yml}] 10 | indent_size = 2 11 | 12 | [*.cs] 13 | indent_size = 4 14 | -------------------------------------------------------------------------------- /e2e/utils/jwt-payload-serializer.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test(argument) { 3 | return argument.iat && argument.exp && argument.nbf; 4 | }, 5 | print(value) { 6 | const { exp, iat, jti, nbf, auth_time, sid, at_hash, ...payload } = value; 7 | return JSON.stringify(payload, undefined, 2); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/Controllers/HealthController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace OpenIdConnectServer.Controllers 4 | { 5 | public class HealthController : Controller 6 | { 7 | [HttpGet("/health")] 8 | public IActionResult Get() 9 | { 10 | return Ok(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /e2e/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as authorizationEndpoint } from './authorization-endpoint'; 2 | export { default as grants } from './grants'; 3 | export { default as introspectEndpoint } from './introspect-endpoint'; 4 | export { default as tokenEndpoint } from './token-endpoint'; 5 | export { default as userInfoEndpoint } from './user-info-endpoint'; 6 | -------------------------------------------------------------------------------- /e2e/tests/__snapshots__/base-path.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Base path Discovery Endpoint 1`] = ` 4 | { 5 | "access-control-allow-origin": "https://google.com", 6 | "content-type": "application/json; charset=UTF-8", 7 | "date": Any, 8 | "server": "Kestrel", 9 | "transfer-encoding": "chunked", 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "outDir": "./dist", 8 | "resolveJsonModule": true, 9 | "rootDir": ".", 10 | "target": "ES2022" 11 | }, 12 | "include": ["e2e/**/*.ts"], 13 | "exclude": ["e2e/node_modules/**/*", "e2e/dist/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /e2e/utils/jwt-serializer.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | test(argument) { 3 | return argument.header && argument.payload && argument.signature; 4 | }, 5 | print(value) { 6 | const { alg, typ } = value.header; 7 | const { exp, iat, jti, nbf, auth_time, sid, at_hash, ...payload } = value.payload; 8 | return JSON.stringify({ alg, typ, ...payload }, undefined, 2); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /Tiltfile: -------------------------------------------------------------------------------- 1 | dockerComps = ['./e2e/docker-compose.yml'] 2 | 3 | imageTag = os.getenv('IMAGE_TAG', '') 4 | if imageTag == '': 5 | docker_build('oidc-server-mock', './src') 6 | else: 7 | dockerComps.append( './e2e/docker-compose.override.yml') 8 | 9 | docker_compose(dockerComps) 10 | 11 | dc_resource('oidc-server-mock') 12 | local_resource('tests', cmd='npm run test --workspace=e2e', resource_deps=['oidc-server-mock']) 13 | -------------------------------------------------------------------------------- /e2e/types/client.ts: -------------------------------------------------------------------------------- 1 | import type Claim from './claim'; 2 | 3 | export default interface Client { 4 | ClientId: string; 5 | ClientSecrets?: string[]; 6 | Description?: string; 7 | AllowedGrantTypes: string[]; 8 | AllowedScopes: string[]; 9 | RedirectUris?: string[]; 10 | AllowAccessTokensViaBrowser?: boolean; 11 | AccessTokenLifetime?: number; 12 | IdentityTokenLifetime?: number; 13 | Claims?: Claim[]; 14 | ClientClaimsPrefix?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/getui.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | TAG="6.3.0" 6 | 7 | git clone -n --depth=1 --filter=tree:0 \ 8 | -b is-7.2.2 --single-branch \ 9 | https://github.com/DuendeSoftware/products 10 | cd products 11 | git sparse-checkout set --no-cone /identity-server/hosts/main 12 | git checkout 13 | 14 | cd - 15 | 16 | [[ -d Pages ]] || mkdir Pages 17 | [[ -d wwwroot ]] || mkdir wwwroot 18 | 19 | cp -r ./products/identity-server/hosts/main/Pages/* Pages 20 | cp -r ./products/identity-server/hosts/main/wwwroot/* wwwroot 21 | 22 | rm -rf products 23 | -------------------------------------------------------------------------------- /e2e/helpers/user-info-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { oidcUserInfoUrl } from './endpoints'; 3 | 4 | const userInfoEndpoint = async ( 5 | token: string, 6 | snapshotPropertyMatchers: Record = {}, 7 | ): Promise => { 8 | const response = await fetch(oidcUserInfoUrl, { 9 | headers: { authorization: `Bearer ${token}` }, 10 | }); 11 | expect(response.ok).toBe(true); 12 | const result = (await response.json()) as unknown; 13 | expect(result).toMatchSnapshot(snapshotPropertyMatchers); 14 | }; 15 | 16 | export default userInfoEndpoint; 17 | -------------------------------------------------------------------------------- /src/Helpers/MergeHelper.cs: -------------------------------------------------------------------------------- 1 | namespace OpenIdConnectServer.Helpers 2 | { 3 | public static class MergeHelper 4 | { 5 | public static void Merge(T source, T target) 6 | { 7 | Type t = typeof(T); 8 | 9 | var properties = t.GetProperties().Where(prop => prop.CanRead && prop.CanWrite); 10 | 11 | foreach (var prop in properties) 12 | { 13 | var value = prop.GetValue(source, null); 14 | if (value != null) 15 | { 16 | prop.SetValue(target, value, null); 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /e2e/helpers/grants.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { Page } from 'playwright-chromium'; 3 | 4 | import { User } from '../types'; 5 | import { oidcGrantsUrl } from './endpoints'; 6 | 7 | const grantsEndpoint = async (page: Page, user: User): Promise => { 8 | const response = await page.goto(oidcGrantsUrl.href); 9 | expect(response.ok()).toBe(true); 10 | 11 | await page.waitForSelector('[id=Input_Username]'); 12 | await page.type('[id=Input_Username]', user.Username); 13 | await page.type('[id=Input_Password]', user.Password); 14 | await page.keyboard.press('Enter'); 15 | await page.waitForNavigation(); 16 | expect(await page.content()).toMatchSnapshot(); 17 | }; 18 | 19 | export default grantsEndpoint; 20 | -------------------------------------------------------------------------------- /src/Services/CorsPolicyService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using Duende.IdentityServer.Services; 3 | 4 | namespace OpenIdConnectServer.Services 5 | { 6 | public class CorsPolicyService : ICorsPolicyService 7 | { 8 | public Task IsOriginAllowedAsync(string origin) 9 | { 10 | var allowedOrigins = Config.GetServerCorsAllowedOrigins(); 11 | if (allowedOrigins != null && allowedOrigins.Count() > 0) 12 | { 13 | return Task.FromResult(allowedOrigins.Any(allowedOrigin => 14 | Regex.Match(origin, Regex.Escape(allowedOrigin).Replace("\\*", "[a-zA-Z0-9.]+?")).Success)); 15 | } 16 | return Task.FromResult(true); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/YamlConverters/SecretYamlConverter.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Models; 2 | using YamlDotNet.Core; 3 | using YamlDotNet.Core.Events; 4 | using YamlDotNet.Serialization; 5 | 6 | namespace OpenIdConnectServer.YamlConverters 7 | { 8 | public class SecretYamlConverter : IYamlTypeConverter // 9 | { 10 | public bool Accepts(Type type) 11 | { 12 | return type == typeof(Secret); 13 | } 14 | 15 | public void WriteYaml(IEmitter emitter, object value, Type type) 16 | { 17 | throw new NotSupportedException(); 18 | } 19 | 20 | public object ReadYaml(IParser parser, Type type) 21 | { 22 | string s = parser.Consume().Value; 23 | return new Secret(s.Sha256()); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Helpers/OptionsHelper.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Linq; 3 | 4 | namespace OpenIdConnectServer.Helpers 5 | { 6 | public class OptionsHelper 7 | { 8 | public static void ConfigureOptions(string optionsStr) 9 | { 10 | var options = JsonConvert.DeserializeObject(optionsStr); 11 | var targetFields = typeof(T).GetFields(); 12 | var jValueValueProp = typeof(JValue).GetProperty(nameof(JValue.Value)); 13 | Array.ForEach(targetFields, k => { 14 | if (options != null && options.ContainsKey(k.Name)) { 15 | var fieldJValue = options[k.Name] as JValue; 16 | var fieldValue = jValueValueProp?.GetValue(fieldJValue); 17 | k.SetValue(null, fieldValue); 18 | } 19 | }); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnPaste": false, 3 | "editor.formatOnSave": true, 4 | "editor.detectIndentation": false, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "[json]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[properties]": { 10 | "editor.defaultFormatter": "foxundermoon.shell-format" 11 | }, 12 | "[dotenv]": { 13 | "editor.defaultFormatter": "foxundermoon.shell-format" 14 | }, 15 | "files.eol": "\n", 16 | "eslint.workingDirectories": [ 17 | { 18 | "mode": "auto" 19 | } 20 | ], 21 | "[typescript]": { 22 | "editor.defaultFormatter": "esbenp.prettier-vscode" 23 | }, 24 | "typescript.tsdk": "node_modules/typescript/lib", 25 | "typescript.enablePromptUseWorkspaceTsdk": true, 26 | "[dockerfile]": { 27 | "editor.defaultFormatter": "ms-azuretools.vscode-docker" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /e2e/tests/flows/__snapshots__/client-credentials-flow.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Client Credentials Flow Introspection Endpoint 1`] = ` 4 | { 5 | "iss": "https://localhost:8443", 6 | "aud": "some-app", 7 | "client_id": "client-credentials-flow-client-id", 8 | "string_claim": "string_claim_value", 9 | "json_claim": [ 10 | "value1", 11 | "value2" 12 | ], 13 | "active": true, 14 | "scope": "some-app-scope-1" 15 | } 16 | `; 17 | 18 | exports[`Client Credentials Flow Token Endpoint 1`] = ` 19 | { 20 | "alg": "RS256", 21 | "typ": "JWT", 22 | "iss": "https://localhost:8443", 23 | "aud": "some-app", 24 | "scope": [ 25 | "some-app-scope-1" 26 | ], 27 | "client_id": "client-credentials-flow-client-id", 28 | "string_claim": "string_claim_value", 29 | "json_claim": [ 30 | "value1", 31 | "value2" 32 | ] 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e2e", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "test": "NODE_TLS_REJECT_UNAUTHORIZED=0 jest --runInBand --ci --config jest.config.ts" 6 | }, 7 | "license": "MIT", 8 | "engines": { 9 | "node": ">=20.0.0" 10 | }, 11 | "dependencies": { 12 | "chance": "^1.1.12", 13 | "dotenv": "^16.5.0", 14 | "env-var": "^7.5.0", 15 | "jws": "^4.0.0", 16 | "playwright-chromium": "^1.52.0", 17 | "wait-on": "^8.0.3", 18 | "yaml": "^2.7.1" 19 | }, 20 | "devDependencies": { 21 | "@jest/types": "^29.6.3", 22 | "@types/chance": "^1.1.6", 23 | "@types/jest": "^29.5.14", 24 | "@types/jws": "^3.2.10", 25 | "@types/node": "^22.15.3", 26 | "@types/wait-on": "^5.3.4", 27 | "jest": "^29.7.0", 28 | "jest-playwright-preset": "^4.0.0", 29 | "ts-jest": "^29.3.2", 30 | "ts-node": "^10.9.2", 31 | "typescript": "^5.8.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import eslintConfigPrettier from 'eslint-config-prettier'; 3 | import globals from 'globals'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | { ignores: ['dist'] }, 8 | { 9 | files: ['**/*.{ts,tsx}'], 10 | languageOptions: { 11 | ecmaVersion: 2020, 12 | globals: globals.browser, 13 | parserOptions: { 14 | project: ['./tsconfig.json'], 15 | tsconfigRootDir: import.meta.dirname, 16 | }, 17 | }, 18 | extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], 19 | settings: { 20 | node: { 21 | allowModules: ['jest-playwright-preset', 'wait-on'], 22 | tryExtensions: ['.ts', '.json', '.node'], 23 | }, 24 | }, 25 | ignores: ['**/node_modules/**', '**/dist/**', '**/coverage/**'], 26 | }, 27 | eslintConfigPrettier, 28 | ); 29 | -------------------------------------------------------------------------------- /e2e/helpers/token-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { decode as decodeJWT } from 'jws'; 3 | import { oidcTokenUrl } from './endpoints'; 4 | 5 | const tokenEndpoint = async ( 6 | parameters: URLSearchParams, 7 | snapshotPropertyMatchers: Record = {}, 8 | ): Promise => { 9 | const response = await fetch(oidcTokenUrl, { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/x-www-form-urlencoded', 13 | }, 14 | body: parameters.toString(), 15 | }); 16 | expect(response.ok).toBe(true); 17 | const result = (await response.json()) as { access_token: string }; 18 | expect(result.access_token).toBeDefined(); 19 | const token = result.access_token; 20 | const decodedToken = decodeJWT(token); 21 | 22 | expect(decodedToken).toMatchSnapshot(snapshotPropertyMatchers); 23 | return token; 24 | }; 25 | 26 | export default tokenEndpoint; 27 | -------------------------------------------------------------------------------- /e2e/helpers/authorization-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { Page } from 'playwright-chromium'; 3 | 4 | import { User } from '../types'; 5 | import { oidcAuthorizeUrl } from './endpoints'; 6 | 7 | const authorizationEndpoint = async ( 8 | page: Page, 9 | parameters: URLSearchParams, 10 | user: User, 11 | redirect_uri: string, 12 | ): Promise => { 13 | const url = `${oidcAuthorizeUrl.href}?${parameters.toString()}`; 14 | const response = await page.goto(url); 15 | expect(response.ok()).toBe(true); 16 | 17 | await page.waitForSelector('[id=Input_Username]'); 18 | await page.type('[id=Input_Username]', user.Username); 19 | await page.type('[id=Input_Password]', user.Password); 20 | await page.keyboard.press('Enter'); 21 | await page.waitForURL(url => url.origin === redirect_uri); 22 | const redirectedUrl = new URL(page.url()); 23 | return redirectedUrl; 24 | }; 25 | 26 | export default authorizationEndpoint; 27 | -------------------------------------------------------------------------------- /src/Validation/RedirectUriValidator.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Models; 2 | using Duende.IdentityServer.Validation; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace OpenIdConnectServer.Validation 6 | { 7 | internal class RedirectUriValidator : IRedirectUriValidator 8 | { 9 | protected bool Validate(string requestedUri, ICollection allowedUris) => 10 | allowedUris.Any(allowedUri => Regex.Match(requestedUri, Regex.Escape(allowedUri).Replace("\\*", "[a-zA-Z0-9.]+?")).Success); 11 | 12 | public Task IsPostLogoutRedirectUriValidAsync(string requestedUri, Client client) 13 | { 14 | return Task.FromResult(Validate(requestedUri, client.PostLogoutRedirectUris)); 15 | } 16 | 17 | public Task IsRedirectUriValidAsync(string requestedUri, Client client) 18 | { 19 | return Task.FromResult(Validate(requestedUri, client.RedirectUris)); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oidc-server-mock", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "type": "module", 7 | "engines": { 8 | "node": ">=20.0.0" 9 | }, 10 | "scripts": { 11 | "tilt:up": "tilt up", 12 | "tilt:down": "tilt down", 13 | "tilt:ci": "tilt ci", 14 | "lint": "eslint .", 15 | "format": "prettier --write **/*.ts", 16 | "prepare": "husky" 17 | }, 18 | "workspaces": [ 19 | "e2e" 20 | ], 21 | "devDependencies": { 22 | "@commitlint/cli": "^19.8.0", 23 | "@commitlint/config-conventional": "^19.8.0", 24 | "@eslint/js": "^9.26.0", 25 | "@jest/globals": "^29.7.0", 26 | "eslint": "^9.26.0", 27 | "eslint-config-prettier": "^10.1.2", 28 | "eslint-plugin-prettier": "^5.2.6", 29 | "globals": "^16.0.0", 30 | "husky": "^9.1.7", 31 | "lint-staged": "^15.5.1", 32 | "prettier": "^3.5.3", 33 | "prettier-plugin-packagejson": "^2.5.10", 34 | "pretty-quick": "^4.1.1", 35 | "typescript": "^5.8.3", 36 | "typescript-eslint": "^8.31.1" 37 | }, 38 | "packageManager": "pnpm@10.10.0" 39 | } 40 | -------------------------------------------------------------------------------- /e2e/helpers/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { from, logger } from 'env-var'; 2 | import * as dotenv from 'dotenv'; 3 | import { URL } from 'node:url'; 4 | dotenv.config(); 5 | const env = from(process.env, undefined, logger); 6 | 7 | export const oidcBaseUrl = env.get('OIDC_BASE_URL').required().asUrlObject(); 8 | export const basePath = 'some-base-path'; 9 | 10 | export const oidcTokenUrl = new URL('/connect/token', oidcBaseUrl); 11 | export const oidcTokenUrlWithBasePath = new URL(`/${basePath}/connect/token`, oidcBaseUrl); 12 | export const oidcAuthorizeUrl = new URL('connect/authorize', oidcBaseUrl); 13 | export const oidcIntrospectionUrl = new URL('/connect/introspect', oidcBaseUrl); 14 | export const oidcUserInfoUrl = new URL('/connect/userinfo', oidcBaseUrl); 15 | export const oidcGrantsUrl = new URL('/grants', oidcBaseUrl); 16 | export const oidcUserManagementUrl = new URL('/api/v1/user', oidcBaseUrl); 17 | export const oidcDiscoveryEndpoint = new URL('/.well-known/openid-configuration', oidcBaseUrl); 18 | export const oidcDiscoveryEndpointWithBasePath = new URL(`/${basePath}/.well-known/openid-configuration`, oidcBaseUrl); 19 | -------------------------------------------------------------------------------- /e2e/tests/flows/client-credentials-flow.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, expect } from '@jest/globals'; 2 | 3 | import clients from '../../config/clients.json'; 4 | import { introspectEndpoint, tokenEndpoint } from '../../helpers'; 5 | import type { Client } from '../../types'; 6 | 7 | describe('Client Credentials Flow', () => { 8 | let client: Client | undefined; 9 | let token: string; 10 | 11 | beforeAll(() => { 12 | client = clients.find(c => c.ClientId === 'client-credentials-flow-client-id'); 13 | expect(client).toBeDefined(); 14 | }); 15 | 16 | test('Token Endpoint', async () => { 17 | if (!client) throw new Error('Client not found'); 18 | 19 | const parameters = new URLSearchParams({ 20 | client_id: client.ClientId, 21 | client_secret: client.ClientSecrets?.[0] ?? '', 22 | grant_type: 'client_credentials', 23 | scope: client.AllowedScopes.join(' '), 24 | }); 25 | 26 | token = await tokenEndpoint(parameters); 27 | }); 28 | 29 | test('Introspection Endpoint', async () => { 30 | await introspectEndpoint(token, 'some-app'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/JsonConverters/ClaimJsonConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Linq; 4 | 5 | namespace OpenIdConnectServer.JsonConverters 6 | { 7 | public class ClaimJsonConverter : JsonConverter 8 | { 9 | public override void WriteJson(JsonWriter writer, Claim value, JsonSerializer serializer) 10 | { 11 | throw new NotSupportedException(); 12 | } 13 | 14 | public override Claim ReadJson(JsonReader reader, Type objectType, Claim existingValue, bool hasExistingValue, 15 | JsonSerializer serializer) 16 | { 17 | var jObject = JObject.Load(reader); 18 | var type = jObject["Type"].Value(); 19 | var value = jObject["Value"].Value(); 20 | var valueType = jObject.ContainsKey("ValueType") ? jObject["ValueType"].Value() : ClaimValueTypes.String; 21 | return new Claim(type, value, valueType); 22 | } 23 | 24 | public override bool CanRead => true; 25 | public override bool CanWrite => false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /e2e/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | projects: [ 5 | { 6 | displayName: 'Backend Tests', 7 | preset: 'ts-jest', 8 | rootDir: '.', 9 | snapshotSerializers: ['/utils/jwt-serializer.js', '/utils/jwt-payload-serializer.js'], 10 | testMatch: ['/tests/**/*.spec.ts'], 11 | testPathIgnorePatterns: ['/node_modules/', '/dist/', '/tests/*.e2e-spec.ts'], 12 | testEnvironment: 'node', 13 | }, 14 | { 15 | displayName: 'Frontend Tests', 16 | preset: 'jest-playwright-preset', 17 | rootDir: '.', 18 | snapshotSerializers: ['/utils/jwt-serializer.js', '/utils/jwt-payload-serializer.js'], 19 | testMatch: ['/tests/**/*.e2e-spec.ts'], 20 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 21 | transform: { 22 | '^.+\\.ts$': 'ts-jest', 23 | }, 24 | testEnvironment: 'node', 25 | }, 26 | ], 27 | testTimeout: 60000, 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - README.md 7 | 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: pr-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | TILT_VERSION: 'v0.34.2' 16 | 17 | jobs: 18 | tests: 19 | name: Tests 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Setup Tilt 25 | run: curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/${TILT_VERSION}/scripts/install.sh | bash 26 | 27 | - name: Setup pnpm 28 | uses: pnpm/action-setup@v4 29 | 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version-file: .nvmrc 33 | cache: pnpm 34 | 35 | - name: Run npm install 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Install playwright dependencies 39 | run: pnpm --filter e2e exec playwright install --with-deps chromium 40 | 41 | - name: Eslint 42 | run: pnpm run lint 43 | 44 | - name: Run Tests 45 | run: pnpm run tilt:ci 46 | -------------------------------------------------------------------------------- /src/Middlewares/BasePathMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Extensions; 2 | using Duende.IdentityServer.Configuration; 3 | using Duende.IdentityServer.Services; 4 | 5 | #pragma warning disable 1591 6 | 7 | namespace OpenIdConnectServer.Middlewares 8 | { 9 | public class BasePathMiddleware 10 | { 11 | private readonly RequestDelegate _next; 12 | private readonly IdentityServerOptions _options; 13 | 14 | public BasePathMiddleware(RequestDelegate next, IdentityServerOptions options) 15 | { 16 | _next = next; 17 | _options = options; 18 | } 19 | 20 | public async Task Invoke(HttpContext context) 21 | { 22 | var basePath = Config.GetAspNetServicesOptions().BasePath; 23 | var request = context.Request; 24 | if(request.Path.Value?.Length > basePath.Length) 25 | { 26 | request.Path = request.Path.Value.Substring(basePath.Length); 27 | context.RequestServices.GetRequiredService().BasePath = basePath; 28 | } 29 | await _next(context); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | 3 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS source 4 | ARG TARGETARCH 5 | 6 | ARG target="Release" 7 | 8 | RUN apk add --no-cache unzip curl bash 9 | 10 | WORKDIR /src 11 | 12 | COPY ./getui.sh ./getui.sh 13 | RUN ./getui.sh 14 | 15 | COPY ./OpenIdConnectServerMock.csproj ./OpenIdConnectServerMock.csproj 16 | RUN dotnet restore -a $TARGETARCH 17 | 18 | COPY . . 19 | 20 | RUN dotnet publish -a $TARGETARCH --no-restore -c $target -o obj/docker/publish 21 | 22 | RUN cp -r /src/obj/docker/publish /OpenIdConnectServerMock 23 | 24 | # Stage 2: Release 25 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS release 26 | 27 | ARG target="Release" 28 | 29 | RUN apk add --no-cache curl 30 | RUN if [ $target = "Debug" ]; then apk add --no-cache bash unzip && curl -sSL https://aka.ms/getvsdbgsh | bash /dev/stdin -v latest -l /vsdbg; fi 31 | 32 | COPY --from=source /OpenIdConnectServerMock /OpenIdConnectServerMock 33 | WORKDIR /OpenIdConnectServerMock 34 | 35 | ENV ASPNETCORE_ENVIRONMENT=Development 36 | 37 | EXPOSE 80 38 | EXPOSE 443 39 | 40 | HEALTHCHECK --start-period=2s --interval=1s --timeout=100ms --retries=10 \ 41 | CMD curl -k --location https://localhost/health || exit 1 42 | 43 | ENTRYPOINT ["dotnet", "OpenIdConnectServerMock.dll" ] 44 | -------------------------------------------------------------------------------- /e2e/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | oidc-server-mock: 3 | container_name: oidc-server-mock 4 | image: oidc-server-mock 5 | environment: 6 | ASPNETCORE_ENVIRONMENT: Development 7 | ASPNETCORE_URLS: https://+:443;http://+:80 8 | ASPNETCORE_Kestrel__Certificates__Default__Password: oidc-server-mock-pwd 9 | ASPNETCORE_Kestrel__Certificates__Default__Path: /https/aspnetapp.pfx 10 | SERVER_OPTIONS_PATH: /config/server-options.json 11 | LOGIN_OPTIONS_INLINE: | 12 | { 13 | "AllowRememberLogin": false 14 | } 15 | LOGOUT_OPTIONS_INLINE: | 16 | { 17 | "AutomaticRedirectAfterSignOut": true 18 | } 19 | API_RESOURCES_PATH: /config/api-resources.yaml 20 | API_SCOPES_INLINE: | 21 | - Name: some-app-scope-1 22 | UserClaims: 23 | - some-app-scope-1-custom-user-claim 24 | - Name: some-app-scope-2 25 | USERS_CONFIGURATION_PATH: /config/users.yaml 26 | CLIENTS_CONFIGURATION_PATH: /config/clients.json 27 | IDENTITY_RESOURCES_PATH: /config/identity-resources.json 28 | ASPNET_SERVICES_OPTIONS_INLINE: | 29 | { 30 | "BasePath": "/some-base-path" 31 | } 32 | volumes: 33 | - ./config:/config:ro 34 | - ./https:/https:ro 35 | ports: 36 | - 8080:80 37 | - 8443:443 38 | -------------------------------------------------------------------------------- /e2e/helpers/introspect-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@jest/globals'; 2 | import { ApiResource } from 'e2e/types'; 3 | import * as fs from 'fs/promises'; 4 | import path from 'path'; 5 | 6 | import * as yaml from 'yaml'; 7 | import { oidcIntrospectionUrl } from './endpoints'; 8 | 9 | const introspectEndpoint = async ( 10 | token: string, 11 | apiResourceId: string, 12 | snapshotPropertyMatchers: Record = {}, 13 | ): Promise => { 14 | const apiResources = yaml.parse( 15 | await fs.readFile(path.join(process.cwd(), './config/api-resources.yaml'), { encoding: 'utf8' }), 16 | ) as ApiResource[]; 17 | const apiResource = apiResources.find(aR => aR.Name === apiResourceId); 18 | expect(apiResource).toBeDefined(); 19 | const auth = Buffer.from(`${apiResource.Name}:${apiResource.ApiSecrets?.[0]}`).toString('base64'); 20 | const headers = { 21 | Authorization: `Basic ${auth}`, 22 | 'Content-Type': 'application/x-www-form-urlencoded', 23 | }; 24 | const requestBody = new URLSearchParams({ 25 | token, 26 | }); 27 | 28 | const response = await fetch(oidcIntrospectionUrl, { 29 | method: 'POST', 30 | body: requestBody, 31 | headers, 32 | }); 33 | 34 | expect(response.ok).toBe(true); 35 | const result = (await response.json()) as unknown; 36 | expect(result).toMatchSnapshot(snapshotPropertyMatchers); 37 | }; 38 | 39 | export default introspectEndpoint; 40 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/src/OpenIdConnectServerMock.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/src/OpenIdConnectServerMock.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "${workspaceFolder}/src/OpenIdConnectServerMock.csproj", 36 | "/property:GenerateFullPaths=true", 37 | "/consoleloggerparameters:NoSummary" 38 | ], 39 | "problemMatcher": "$msCompile" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /e2e/tests/custom-endpoints/__snapshots__/user-management.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`User management Introspection Endpoint 1`] = ` 4 | { 5 | "iss": "https://localhost:8443", 6 | "aud": "some-app", 7 | "amr": "pwd", 8 | "client_id": "password-flow-client-id", 9 | "sub": { 10 | "inverse": false 11 | }, 12 | "idp": "local", 13 | "some-app-user-custom-claim": { 14 | "inverse": false 15 | }, 16 | "some-app-scope-1-custom-user-claim": { 17 | "inverse": false 18 | }, 19 | "active": true, 20 | "scope": "some-app-scope-1" 21 | } 22 | `; 23 | 24 | exports[`User management Token Endpoint 1`] = ` 25 | { 26 | "alg": "RS256", 27 | "typ": "JWT", 28 | "iss": "https://localhost:8443", 29 | "aud": "some-app", 30 | "scope": [ 31 | "email", 32 | "openid", 33 | "profile", 34 | "some-app-scope-1", 35 | "some-custom-identity" 36 | ], 37 | "amr": [ 38 | "pwd" 39 | ], 40 | "client_id": "password-flow-client-id", 41 | "sub": { 42 | "inverse": false 43 | }, 44 | "idp": "local", 45 | "some-app-user-custom-claim": { 46 | "inverse": false 47 | }, 48 | "some-app-scope-1-custom-user-claim": { 49 | "inverse": false 50 | } 51 | } 52 | `; 53 | 54 | exports[`User management UserInfo Endpoint 1`] = ` 55 | { 56 | "email": Any, 57 | "name": Any, 58 | "some-custom-identity-user-claim": Any, 59 | "sub": Any, 60 | } 61 | `; 62 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/src/bin/Debug/netcoreapp3.1/OpenIdConnectServerMock.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}/src", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "^\\s*Now listening on:\\s+(https?://\\S+)" 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": ".NET Core Attach", 31 | "type": "coreclr", 32 | "request": "attach", 33 | "processId": "${command:pickProcess}" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /src/OpenIdConnectServerMock.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | true 10 | Configurable mock server with OpenId Connect functionality 11 | 0.11.1 12 | https://github.com/Soluto/oidc-server-mock 13 | Apache-2.0 14 | OIDC 15 | https://github.com/Soluto/oidc-server-mock 16 | git 17 | true 18 | oidc-mock 19 | false 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/tempkey.rsa: -------------------------------------------------------------------------------- 1 | {"KeyId":"73239b28fa5e71f10e0f478fb4d25d01","Parameters":{"D":"prsrSaYh19e1aP81scihMxY2ibjG61GZd53on0LbRm21vwAlhzNcY9BAx8Y9xEFpNIqAhO6R2VLqD+PSPvO+y7IojXemeDcmEoLJQKuzGvVQfxBmDYQ3GUChs7F4hkTqnVs32VXHF08ValDBd/1/nmgSi0fsvFx71kcArp7Qx61yUmoaN+nLEzE8csQjipFzlXcH/yHMdIWooP2rqPVP5cLv7WIcIvDVZNiAwEVSY1KiGARHNsWbEm5HtJTK66I15Zp0dZp4oVXVZJIZye78Pjo5X0vmGm7F/x+cc6WTHsFtDUP31NPxv7b5umqnoUYxUyM1wdYy6TYmghXh59TNnQ==","DP":"5lfdYu8v+rINrLorfha7fdglzjk9OOT30kniQbnsds0EYgs8IeyPPf1GAmJKPyOobcDM+kWQKlvvgCPdk9IgsDRgmZycdVwKggtQpQzkJrIOcJe3h8L+hPLSlcXE5yTY3+qXn+mmloDSale5G4fd3M/dhAe5dAugJDCsLC16EM0=","DQ":"000qbMhWiCYORQ8wZIT7g/rDxcswx6GxmcBr7k6TSYISWynlqPctytGm0tQZZoiFXXtvhlMDCFas2pRbST3FZSKMgccjuBu0in29y/xrpbL70zQSHEmvQNKhWS6lwlLN17jlGEFGQpakpKuT6Qdx6dhfvWE2QQyxEdD2TQwr+QU=","Exponent":"AQAB","InverseQ":"kPvqNF07ozNcO/y+gA6kOj03ExEWmNUKjNvqMrmydsY2XBBaqEbZcj+GC2J3jGljPzJsG3dNLl4p01zR8prwk5nkaIgBZWzbBp/J68c1tw+hD/NQJiIEFgnI4t7Jwr235J8bKVFgl0gqdVYLxdNIrOQVRwrBAXhFR4YRs8tQ1Ww=","Modulus":"zAD8Egjll/WgBekLAI+MvPm0JnsI7HMrKzK8E2IBKaq5+HeVZY/mzIiUWnDoI4RHAyrXzqJBZDkornUxJoRtSwbvpdeOZvJk9XS3kLh3Ior5LAl82jNrgDw8G4aG/4sUnWx3EUkOMf5Zi1hCr56c2brO4Pqh/rcjgotVjfhg9Sne4v86+NaaUCDuGXbfS7cJtquSva2Lk63R/FwFcj2RBXTojRbpiCRKDJoESrBnlPNJBYmySFzEmN1B95VfIZQ9s7fwUC6tWr5G6QDDpwBa+JnCaHC6GyYKskfAkzF9+dFXGghpXPjOnp/bXqZRyibVWUQBA29ugmATGyRS/zANSQ==","P":"6LnaI7YJVlHy2FoT+IaqBY4twiEeHvc7xbEaDcxK3Kb4LJRKR4/8ABS6PxlxN73KSNPVIuPaxn+b1e1ApuR+hXZclElN6+fo0Wnl1+ib+9NIGVh9uINuQoNre+agRFyqixuJnKlJjqrL7OAV7KJKIrSO4dhRoWmGXXl0OwwwJEc=","Q":"4GfNELaLgXEjTK1tYF+2fmMuBmrf2EBsCiFtQZjgYYbudQ2m2osIqXP44h5Vlbmb4bQBPDiOntvnvsInkO0E9x0jGvz+k/9KBofjKtARRCty4Rf94KMQmImatgRLG3ko8F8rI+TQM35/zVtXubCLdY0gycisyJBw1M5+OGmS2e8="}} -------------------------------------------------------------------------------- /src/Services/ProfileService.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Extensions; 2 | using Duende.IdentityServer.Models; 3 | using Duende.IdentityServer.Services; 4 | using Duende.IdentityServer.Test; 5 | 6 | namespace OpenIdConnectServer.Services 7 | { 8 | internal class ProfileService : IProfileService 9 | { 10 | private readonly TestUserStore _userStore; 11 | private readonly ILogger Logger; 12 | 13 | public ProfileService(TestUserStore userStore, ILogger logger) 14 | { 15 | _userStore = userStore; 16 | Logger = logger; 17 | } 18 | 19 | public Task GetProfileDataAsync(ProfileDataRequestContext context) 20 | { 21 | var subjectId = context.Subject.GetSubjectId(); 22 | Logger.LogDebug("Getting profile data for subjectId: {subjectId}", subjectId); 23 | var user = this._userStore.FindBySubjectId(subjectId); 24 | if (user != null) 25 | { 26 | Logger.LogDebug("The user was found in store"); 27 | var claims = context.FilterClaims(user.Claims); 28 | context.AddRequestedClaims(claims); 29 | } 30 | return Task.CompletedTask; 31 | } 32 | 33 | public Task IsActiveAsync(IsActiveContext context) 34 | { 35 | var subjectId = context.Subject.GetSubjectId(); 36 | Logger.LogDebug("Checking if the user is active for subjectId: {subject}", subjectId); 37 | var user = this._userStore.FindBySubjectId(subjectId); 38 | context.IsActive = user?.IsActive ?? false; 39 | Logger.LogDebug("The user is active: {isActive}", context.IsActive); 40 | return Task.CompletedTask; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Controllers/UserController.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Reflection; 3 | using System.Security.Claims; 4 | using Duende.IdentityServer.Test; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace OpenIdConnectServer.Controllers 9 | { 10 | [Route("api/v1/user")] 11 | public class UserController: Controller 12 | { 13 | private readonly TestUserStore _usersStore; 14 | private readonly ILogger Logger; 15 | 16 | public UserController(TestUserStore userStore, ILogger logger) 17 | { 18 | _usersStore = userStore; 19 | Logger = logger; 20 | } 21 | 22 | [HttpGet("{subjectId}")] 23 | public IActionResult GetUser([FromRoute]string subjectId) 24 | { 25 | var user = _usersStore.FindBySubjectId(subjectId); 26 | Logger.LogDebug("User found: {subjectId}", subjectId); 27 | return Json(user); 28 | } 29 | 30 | [HttpPost] 31 | public IActionResult AddUser([FromBody]TestUser user) 32 | { 33 | var claims = new List(user.Claims); 34 | claims.Add(new Claim(ClaimTypes.Name, user.Username)); 35 | var newUser =_usersStore.AutoProvisionUser("Alex", user.SubjectId, new List(user.Claims)); 36 | newUser.SubjectId = user.SubjectId; 37 | newUser.Username = user.Username; 38 | newUser.Password = user.Password; 39 | newUser.ProviderName = string.Empty; 40 | newUser.ProviderSubjectId = string.Empty; 41 | 42 | Logger.LogDebug("New user added: {user}", user.SubjectId); 43 | 44 | return Json(user.SubjectId); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /e2e/tests/flows/password-flow.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, expect } from '@jest/globals'; 2 | 3 | import * as fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import * as yaml from 'yaml'; 7 | 8 | import clients from '../../config/clients.json'; 9 | import { introspectEndpoint, tokenEndpoint, userInfoEndpoint } from '../../helpers'; 10 | import type { Client, User } from '../../types'; 11 | 12 | const users = yaml.parse( 13 | fs.readFileSync(path.join(process.cwd(), './config/users.yaml'), { encoding: 'utf8' }), 14 | ) as User[]; 15 | 16 | const testCases: User[] = users 17 | .map(u => ({ 18 | ...u, 19 | toString: function () { 20 | return (this as User).SubjectId; 21 | }, 22 | })) 23 | .sort((u1, u2) => (u1.SubjectId < u2.SubjectId ? -1 : 1)); 24 | 25 | describe('Password Flow', () => { 26 | let client: Client | undefined; 27 | let token: string; 28 | 29 | beforeAll(() => { 30 | client = clients.find(c => c.ClientId === 'password-flow-client-id'); 31 | expect(client).toBeDefined(); 32 | }); 33 | 34 | describe.each(testCases)('- %s -', (user: User) => { 35 | test('Token Endpoint', async () => { 36 | if (!client) throw new Error('Client not found'); 37 | 38 | const parameters = new URLSearchParams({ 39 | client_id: client.ClientId, 40 | username: user.Username, 41 | password: user.Password, 42 | grant_type: 'password', 43 | scope: client.AllowedScopes.join(' '), 44 | }); 45 | 46 | token = await tokenEndpoint(parameters); 47 | }); 48 | 49 | test('UserInfo Endpoint', async () => { 50 | await userInfoEndpoint(token); 51 | }); 52 | 53 | test('Introspection Endpoint', async () => { 54 | await introspectEndpoint(token, 'some-app'); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /e2e/tests/base-path.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, expect } from '@jest/globals'; 2 | 3 | import clients from '../config/clients.json'; 4 | import type { Client } from '../types'; 5 | import { oidcDiscoveryEndpointWithBasePath, oidcTokenUrlWithBasePath } from 'e2e/helpers/endpoints'; 6 | 7 | describe('Base path', () => { 8 | let client: Client | undefined; 9 | 10 | beforeAll(() => { 11 | client = clients.find(c => c.ClientId === 'client-credentials-flow-client-id'); 12 | expect(client).toBeDefined(); 13 | }); 14 | 15 | test('Discovery Endpoint', async () => { 16 | const response = await fetch(oidcDiscoveryEndpointWithBasePath, { 17 | headers: { 18 | origin: 'https://google.com', 19 | }, 20 | }); 21 | expect(response.ok).toBe(true); 22 | const result = (await response.json()) as unknown; 23 | expect(result).toHaveProperty('token_endpoint', oidcTokenUrlWithBasePath.href); 24 | expect(Object.fromEntries(response.headers.entries())).toMatchSnapshot({ date: expect.any(String) }); 25 | }); 26 | 27 | test('Token Endpoint', async () => { 28 | if (!client) throw new Error('Client not found'); 29 | 30 | const parameters = new URLSearchParams({ 31 | client_id: client.ClientId, 32 | client_secret: client.ClientSecrets?.[0] ?? '', 33 | grant_type: 'client_credentials', 34 | scope: client.AllowedScopes.join(' '), 35 | }); 36 | 37 | const response = await fetch(oidcTokenUrlWithBasePath, { 38 | method: 'POST', 39 | headers: { 40 | 'Content-Type': 'application/x-www-form-urlencoded', 41 | }, 42 | body: parameters.toString(), 43 | }); 44 | expect(response.ok).toBe(true); 45 | const result = (await response.json()) as { access_token: string }; 46 | expect(result.access_token).toBeDefined(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/YamlConverters/ClaimYamlConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using YamlDotNet.Core; 3 | using YamlDotNet.Core.Events; 4 | using YamlDotNet.Serialization; 5 | 6 | namespace OpenIdConnectServer.YamlConverters 7 | { 8 | public class ClaimYamlConverter : IYamlTypeConverter 9 | { 10 | public bool Accepts(Type type) 11 | { 12 | return type == typeof(Claim); 13 | } 14 | 15 | #nullable enable 16 | public void WriteYaml(IEmitter emitter, object? value, Type type) 17 | { 18 | throw new NotSupportedException(); 19 | } 20 | #nullable disable 21 | 22 | 23 | public object ReadYaml(IParser parser, Type type) 24 | { 25 | if (parser.Current.GetType() != typeof(MappingStart)) // You could also use parser.Accept() 26 | { 27 | throw new InvalidDataException("Invalid YAML content."); 28 | } 29 | string claimType = "", claimValue = "", claimValueType = ""; 30 | 31 | parser.MoveNext(); // move on from the map start 32 | 33 | do 34 | { 35 | var scalar = parser.Consume(); 36 | switch (scalar.Value) 37 | { 38 | case "Type": 39 | claimType = parser.Consume().Value; 40 | break; 41 | 42 | case "Value": 43 | claimValue = parser.Consume().Value; 44 | break; 45 | 46 | case "ValueType": 47 | claimValueType = parser.Consume().Value; 48 | break; 49 | } 50 | } while (parser.Current.GetType() != typeof(MappingEnd)); 51 | 52 | parser.MoveNext(); // skip the mapping end (or crash) 53 | 54 | if (string.IsNullOrEmpty(claimType)) throw new InvalidDataException("Type is required property of Claim"); 55 | if (string.IsNullOrEmpty(claimValue)) throw new InvalidDataException("Value is required property of Claim"); 56 | if (string.IsNullOrEmpty(claimValueType)) claimValueType = ClaimValueTypes.String; 57 | return new Claim(claimType, claimValue, claimValueType); 58 | } 59 | 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /e2e/config/users.yaml: -------------------------------------------------------------------------------- 1 | [ 2 | { 'SubjectId': 'simple_user', 'Username': 'simple_user', 'Password': 'pwd' }, 3 | { 4 | 'SubjectId': 'user_with_standard_claims', 5 | 'Username': 'user_with_standard_claims', 6 | 'Password': 'pwd', 7 | 'Claims': 8 | [ 9 | { 'Type': 'name', 'Value': 'John Smith', 'ValueType': 'string' }, 10 | { 'Type': 'email', 'Value': 'john.smith@gmail.com', 'ValueType': 'emailaddress' }, 11 | { 'Type': 'email_verified', 'Value': 'true', 'ValueType': 'boolean' }, 12 | ], 13 | }, 14 | { 15 | 'SubjectId': 'user_with_custom_identity_claims', 16 | 'Username': 'user_with_custom_identity_claims', 17 | 'Password': 'pwd', 18 | 'Claims': 19 | [ 20 | { 'Type': 'name', 'Value': 'Jack Sparrow', 'ValueType': 'string' }, 21 | { 'Type': 'email', 'Value': 'jack.sparrow@gmail.com', 'ValueType': 'emailaddress' }, 22 | { 'Type': 'some-custom-identity-user-claim', 'Value': "Jack's Custom User Claim", 'ValueType': 'string' }, 23 | ], 24 | }, 25 | { 26 | 'SubjectId': 'user_with_custom_api_resource_claims', 27 | 'Username': 'user_with_custom_api_resource_claims', 28 | 'Password': 'pwd', 29 | 'Claims': 30 | [ 31 | { 'Type': 'name', 'Value': 'Sam Tailor', 'ValueType': 'string' }, 32 | { 'Type': 'email', 'Value': 'sam.tailor@gmail.com', 'ValueType': 'emailaddress' }, 33 | { 'Type': 'some-app-user-custom-claim', 'Value': "Sam's Custom User Claim", 'ValueType': 'string' }, 34 | { 35 | 'Type': 'some-app-scope-1-custom-user-claim', 36 | 'Value': "Sam's Scope Custom User Claim", 37 | 'ValueType': 'string', 38 | }, 39 | ], 40 | }, 41 | { 42 | 'SubjectId': 'user_with_all_claim_types', 43 | 'Username': 'user_with_all_claim_types', 44 | 'Password': 'pwd', 45 | 'Claims': 46 | [ 47 | { 'Type': 'name', 'Value': 'Oliver Hunter', 'ValueType': 'string' }, 48 | { 'Type': 'email', 'Value': 'oliver.hunter@gmail.com', 'ValueType': 'emailaddress' }, 49 | { 'Type': 'some-app-user-custom-claim', 'Value': "Oliver's Custom User Claim", 'ValueType': 'string' }, 50 | { 51 | 'Type': 'some-app-scope-1-custom-user-claim', 52 | 'Value': "Oliver's Scope Custom User Claim", 53 | 'ValueType': 'string', 54 | }, 55 | { 'Type': 'some-custom-identity-user-claim', 'Value': "Oliver's Custom User Claim", 'ValueType': 'string' }, 56 | ], 57 | }, 58 | ] 59 | -------------------------------------------------------------------------------- /e2e/config/clients.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ClientId": "implicit-flow-client-id", 4 | "Description": "Client for implicit flow", 5 | "AllowedGrantTypes": ["implicit"], 6 | "AllowAccessTokensViaBrowser": true, 7 | "RedirectUris": ["https://*.google.com"], 8 | "AllowedScopes": ["openid", "profile", "email", "some-custom-identity", "some-app-scope-1"], 9 | "IdentityTokenLifetime": 3600, 10 | "AccessTokenLifetime": 3600 11 | }, 12 | { 13 | "ClientId": "client-credentials-flow-client-id", 14 | "ClientSecrets": ["client-credentials-flow-client-secret"], 15 | "Description": "Client for client credentials flow", 16 | "AllowedGrantTypes": ["client_credentials"], 17 | "AllowedScopes": ["some-app-scope-1"], 18 | "ClientClaimsPrefix": "", 19 | "Claims": [ 20 | { 21 | "Type": "string_claim", 22 | "Value": "string_claim_value", 23 | "ValueType": "string" 24 | }, 25 | { 26 | "Type": "json_claim", 27 | "Value": "[\"value1\", \"value2\"]", 28 | "ValueType": "json" 29 | } 30 | ] 31 | }, 32 | { 33 | "ClientId": "password-flow-client-id", 34 | "ClientSecrets": ["password-flow-client-secret"], 35 | "Description": "Client for password flow", 36 | "AllowedGrantTypes": ["password"], 37 | "AllowedScopes": ["openid", "profile", "email", "some-custom-identity", "some-app-scope-1"], 38 | "ClientClaimsPrefix": "", 39 | "Claims": [ 40 | { 41 | "Type": "string_claim", 42 | "Value": "string_claim_value", 43 | "ValueType": "string" 44 | }, 45 | { 46 | "Type": "json_claim", 47 | "Value": "[\"value1\", \"value2\"]", 48 | "ValueType": "json" 49 | } 50 | ], 51 | "RequireClientSecret": false 52 | }, 53 | { 54 | "ClientId": "authorization-code-client-id", 55 | "ClientSecrets": ["authorization-code-client-secret"], 56 | "Description": "Client for authorization code flow", 57 | "AllowedGrantTypes": ["authorization_code"], 58 | "AllowAccessTokensViaBrowser": true, 59 | "RedirectUris": ["https://*.google.com"], 60 | "RequirePkce": false, 61 | "AllowedScopes": ["openid", "profile", "email", "some-custom-identity", "some-app-scope-1"], 62 | "IdentityTokenLifetime": 3600, 63 | "AccessTokenLifetime": 3600, 64 | "RequireClientSecret": false 65 | }, 66 | { 67 | "ClientId": "authorization-code-with-pkce-client-id", 68 | "ClientSecrets": ["authorization-code-with-pkce-client-secret"], 69 | "Description": "Client for authorization code flow", 70 | "AllowedGrantTypes": ["authorization_code"], 71 | "AllowAccessTokensViaBrowser": true, 72 | "RedirectUris": ["https://*.google.com"], 73 | "AllowedScopes": ["openid", "profile", "email", "some-custom-identity", "some-app-scope-1"], 74 | "IdentityTokenLifetime": 3600, 75 | "AccessTokenLifetime": 3600, 76 | "RequireClientSecret": false 77 | } 78 | ] 79 | -------------------------------------------------------------------------------- /e2e/tests/flows/authorization-code.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, afterAll, beforeEach, afterEach, expect } from '@jest/globals'; 2 | import * as fs from 'fs'; 3 | import path from 'path'; 4 | 5 | import { Browser, BrowserContext, chromium, Page } from 'playwright-chromium'; 6 | import * as yaml from 'yaml'; 7 | 8 | import clients from '../../config/clients.json'; 9 | import { authorizationEndpoint, introspectEndpoint, tokenEndpoint, userInfoEndpoint } from '../../helpers'; 10 | import type { Client, User } from '../../types'; 11 | 12 | const users = yaml.parse( 13 | fs.readFileSync(path.join(process.cwd(), './config/users.yaml'), { encoding: 'utf8' }), 14 | ) as User[]; 15 | 16 | const testCases: User[] = users 17 | .map(u => ({ 18 | ...u, 19 | toString: function () { 20 | return (this as User).SubjectId; 21 | }, 22 | })) 23 | .sort((u1, u2) => (u1.SubjectId < u2.SubjectId ? -1 : 1)); 24 | 25 | describe('Authorization Code Flow', () => { 26 | let code: string; 27 | let token: string; 28 | 29 | let browser: Browser; 30 | let context: BrowserContext; 31 | let page: Page; 32 | let client: Client | undefined; 33 | 34 | beforeAll(async () => { 35 | browser = await chromium.launch(); 36 | client = clients.find(c => c.ClientId === 'authorization-code-client-id'); 37 | expect(client).toBeDefined(); 38 | }); 39 | 40 | beforeEach(async () => { 41 | context = await browser.newContext({ ignoreHTTPSErrors: true }); 42 | page = await context.newPage(); 43 | }); 44 | 45 | afterEach(async () => { 46 | await page.close(); 47 | await context.close(); 48 | }); 49 | 50 | afterAll(async () => { 51 | await browser.close(); 52 | }); 53 | 54 | describe.each(testCases)('- %s -', (user: User) => { 55 | test('Authorization Endpoint', async () => { 56 | const parameters = new URLSearchParams({ 57 | client_id: client.ClientId, 58 | scope: client.AllowedScopes.join(' '), 59 | response_type: 'code', 60 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 61 | state: 'abc', 62 | nonce: 'xyz', 63 | }); 64 | const redirectedUrl = await authorizationEndpoint(page, parameters, user, parameters.get('redirect_uri')); 65 | expect(redirectedUrl.searchParams.has('code')).toBeTruthy(); 66 | code = redirectedUrl.searchParams.get('code'); 67 | }); 68 | 69 | test('Token Endpoint', async () => { 70 | const parameters = new URLSearchParams({ 71 | client_id: client.ClientId, 72 | code, 73 | grant_type: 'authorization_code', 74 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 75 | scope: client.AllowedScopes.join(' '), 76 | }); 77 | 78 | token = await tokenEndpoint(parameters); 79 | }); 80 | 81 | test('UserInfo Endpoint', async () => { 82 | await userInfoEndpoint(token); 83 | }); 84 | 85 | test('Introspection Endpoint', async () => { 86 | await introspectEndpoint(token, 'some-app'); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/Program.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Hosting; 2 | using Microsoft.Extensions.FileProviders; 3 | using OpenIdConnectServer; 4 | using OpenIdConnectServer.Helpers; 5 | using OpenIdConnectServer.JsonConverters; 6 | using OpenIdConnectServer.Middlewares; 7 | using OpenIdConnectServer.Services; 8 | using OpenIdConnectServer.Validation; 9 | using Serilog; 10 | using Serilog.Events; 11 | using Serilog.Sinks.SystemConsole.Themes; 12 | 13 | Log.Logger = new LoggerConfiguration() 14 | .MinimumLevel.Debug() 15 | .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) 16 | .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) 17 | .MinimumLevel.Override("System", LogEventLevel.Warning) 18 | .MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Information) 19 | .Enrich.FromLogContext() 20 | .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}", theme: AnsiConsoleTheme.Code) 21 | .CreateLogger(); 22 | 23 | var builder = WebApplication.CreateBuilder(args); 24 | 25 | // Configure Serilog 26 | builder.Host.UseSerilog(); 27 | 28 | // Add services to the container. 29 | builder.Services.AddRazorPages(); 30 | 31 | builder.Services 32 | .AddControllersWithViews() 33 | .AddNewtonsoftJson(options => 34 | { 35 | options.SerializerSettings.Converters.Add(new ClaimJsonConverter()); 36 | }); 37 | 38 | builder.Services 39 | .AddIdentityServer(options => 40 | { 41 | var configuredOptions = Config.GetServerOptions(); 42 | MergeHelper.Merge(configuredOptions, options); 43 | }) 44 | .AddDeveloperSigningCredential() 45 | .AddInMemoryIdentityResources(Config.GetIdentityResources()) 46 | .AddInMemoryApiResources(Config.GetApiResources()) 47 | .AddInMemoryApiScopes(Config.GetApiScopes()) 48 | .AddInMemoryClients(Config.GetClients()) 49 | .AddTestUsers(Config.GetUsers()) 50 | .AddRedirectUriValidator() 51 | .AddProfileService() 52 | .AddCorsPolicyService(); 53 | 54 | var app = builder.Build(); 55 | 56 | app.UsePathBase(Config.GetAspNetServicesOptions().BasePath); 57 | 58 | var aspNetServicesOptions = Config.GetAspNetServicesOptions(); 59 | AspNetServicesHelper.ConfigureAspNetServices(builder.Services, aspNetServicesOptions); 60 | AspNetServicesHelper.UseAspNetServices(app, aspNetServicesOptions); 61 | 62 | // Config.ConfigureOptions("LOGIN"); 63 | // Config.ConfigureOptions("LOGOUT"); 64 | 65 | app.UseDeveloperExceptionPage(); 66 | 67 | app.UseIdentityServer(); 68 | 69 | app.UseHttpsRedirection(); 70 | 71 | var manifestEmbeddedProvider = new ManifestEmbeddedFileProvider(typeof(Program).Assembly, "wwwroot"); 72 | app.UseStaticFiles(new StaticFileOptions 73 | { 74 | FileProvider = manifestEmbeddedProvider 75 | }); 76 | 77 | app.UseRouting(); 78 | app.UseAuthorization(); 79 | app.MapDefaultControllerRoute(); 80 | app.MapRazorPages(); 81 | 82 | app.Run(); 83 | -------------------------------------------------------------------------------- /src/Helpers/AspNetServicesHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.Cookies; 3 | using AspNetCorsOptions = Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions; 4 | 5 | namespace OpenIdConnectServer.Helpers 6 | { 7 | public class AspNetServicesOptions 8 | { 9 | public AspNetCorsOptions? Cors { get; set; } 10 | 11 | public IDictionary? Authentication { get; set; } 12 | public SessionOptions? Session { get; set; } 13 | 14 | public ForwardedHeadersOptions? ForwardedHeadersOptions { get; set; } 15 | 16 | public string? BasePath { get; set; } 17 | } 18 | 19 | public class AuthenticationOptions 20 | { 21 | public CookieAuthenticationOptions? CookieAuthenticationOptions { get; set; } 22 | } 23 | 24 | public static class AspNetServicesHelper 25 | { 26 | public static void ConfigureAspNetServices(IServiceCollection services, AspNetServicesOptions config) 27 | { 28 | if (config.Authentication != null) 29 | { 30 | ConfigureAuthenticationOptions(services, config.Authentication); 31 | } 32 | 33 | if (config.Session != null) 34 | { 35 | ConfigureSessionOptions(services, config.Session); 36 | } 37 | } 38 | 39 | public static void UseAspNetServices(IApplicationBuilder app, AspNetServicesOptions config) 40 | { 41 | if (config.Cors != null) 42 | { 43 | app.UseCors(); 44 | } 45 | 46 | if (config.ForwardedHeadersOptions != null) 47 | { 48 | config.ForwardedHeadersOptions.KnownNetworks.Clear(); 49 | config.ForwardedHeadersOptions.KnownProxies.Clear(); 50 | app.UseForwardedHeaders(config.ForwardedHeadersOptions); 51 | } 52 | } 53 | 54 | public static void ConfigureAuthenticationOptions(IServiceCollection services, IDictionary config) 55 | { 56 | foreach (var schemaConfig in config) 57 | { 58 | var builder = services.AddAuthentication(schemaConfig.Key); 59 | ConfigureAuthenticationOptionsForScheme(builder, schemaConfig.Value); 60 | } 61 | } 62 | 63 | private static void ConfigureAuthenticationOptionsForScheme(AuthenticationBuilder builder, AuthenticationOptions schemaConfig) 64 | { 65 | builder.AddCookie(options => { 66 | MergeHelper.Merge(schemaConfig.CookieAuthenticationOptions, options); 67 | }); 68 | } 69 | 70 | private static void ConfigureSessionOptions(IServiceCollection services, SessionOptions config) 71 | { 72 | services.AddSession(options => { 73 | MergeHelper.Merge(config, options); 74 | }); 75 | } 76 | 77 | private static void ConfigureCors(IServiceCollection services, AspNetCorsOptions corsConfig) 78 | { 79 | services.AddCors(options => 80 | { 81 | MergeHelper.Merge(corsConfig, options); 82 | } 83 | ); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /e2e/tests/flows/authorization-code-pkce.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, afterAll, beforeEach, afterEach, expect } from '@jest/globals'; 2 | import * as crypto from 'crypto'; 3 | import * as fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import { Browser, BrowserContext, chromium, Page } from 'playwright-chromium'; 7 | import * as yaml from 'yaml'; 8 | 9 | import clients from '../../config/clients.json'; 10 | import { authorizationEndpoint, introspectEndpoint, tokenEndpoint, userInfoEndpoint } from '../../helpers'; 11 | import type { Client, User } from '../../types'; 12 | 13 | const users = yaml.parse( 14 | fs.readFileSync(path.join(process.cwd(), './config/users.yaml'), { encoding: 'utf8' }), 15 | ) as User[]; 16 | 17 | const testCases: User[] = users 18 | .map(u => ({ 19 | ...u, 20 | toString: function () { 21 | return (this as User).SubjectId; 22 | }, 23 | })) 24 | .sort((u1, u2) => (u1.SubjectId < u2.SubjectId ? -1 : 1)); 25 | 26 | const base64URLEncode = (buffer: Buffer) => 27 | buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); 28 | 29 | const sha256 = (buffer: crypto.BinaryLike) => crypto.createHash('sha256').update(buffer).digest(); 30 | 31 | describe('Authorization Code Flow (with PKCE)', () => { 32 | let codeVerifier: string; 33 | let code: string; 34 | let token: string; 35 | 36 | let browser: Browser; 37 | let context: BrowserContext; 38 | let page: Page; 39 | let client: Client | undefined; 40 | 41 | beforeAll(async () => { 42 | browser = await chromium.launch(); 43 | client = clients.find(c => c.ClientId === 'authorization-code-with-pkce-client-id'); 44 | expect(client).toBeDefined(); 45 | }); 46 | 47 | beforeEach(async () => { 48 | context = await browser.newContext({ ignoreHTTPSErrors: true }); 49 | page = await context.newPage(); 50 | }); 51 | 52 | afterEach(async () => { 53 | await page.close(); 54 | await context.close(); 55 | }); 56 | 57 | afterAll(async () => { 58 | await browser.close(); 59 | }); 60 | 61 | describe.each(testCases)('- %s -', (user: User) => { 62 | test('Authorization Endpoint', async () => { 63 | codeVerifier = base64URLEncode(crypto.randomBytes(32)); 64 | 65 | const codeChallenge = base64URLEncode(sha256(codeVerifier)); 66 | 67 | const parameters = new URLSearchParams({ 68 | client_id: client.ClientId, 69 | scope: client.AllowedScopes.join(' '), 70 | response_type: 'code', 71 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 72 | code_challenge: codeChallenge, 73 | code_challenge_method: 'S256', 74 | state: 'abc', 75 | nonce: 'xyz', 76 | }); 77 | const redirectedUrl = await authorizationEndpoint(page, parameters, user, parameters.get('redirect_uri')); 78 | expect(redirectedUrl.searchParams.has('code')).toBeTruthy(); 79 | code = redirectedUrl.searchParams.get('code'); 80 | }); 81 | 82 | test('Token Endpoint', async () => { 83 | const parameters = new URLSearchParams({ 84 | client_id: client.ClientId, 85 | code, 86 | grant_type: 'authorization_code', 87 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 88 | code_verifier: codeVerifier, 89 | scope: client.AllowedScopes.join(' '), 90 | }); 91 | 92 | token = await tokenEndpoint(parameters); 93 | }); 94 | 95 | test('UserInfo Endpoint', async () => { 96 | await userInfoEndpoint(token); 97 | }); 98 | 99 | test('Introspection Endpoint', async () => { 100 | await introspectEndpoint(token, 'some-app'); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /e2e/tests/flows/implicit-flow.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, afterAll, beforeEach, afterEach, expect } from '@jest/globals'; 2 | 3 | import * as fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import { decode as decodeJWT } from 'jws'; 7 | import { Browser, BrowserContext, chromium, Page } from 'playwright-chromium'; 8 | import * as yaml from 'yaml'; 9 | 10 | import clients from '../../config/clients.json'; 11 | import { authorizationEndpoint, introspectEndpoint, userInfoEndpoint } from '../../helpers'; 12 | import type { Client, User } from '../../types'; 13 | 14 | const users = yaml.parse( 15 | fs.readFileSync(path.join(process.cwd(), './config/users.yaml'), { encoding: 'utf8' }), 16 | ) as User[]; 17 | 18 | const testCases: User[] = users 19 | .map(u => ({ 20 | ...u, 21 | toString: function () { 22 | return (this as User).SubjectId; 23 | }, 24 | })) 25 | .sort((u1, u2) => (u1.SubjectId < u2.SubjectId ? -1 : 1)); 26 | 27 | describe('Implicit Flow', () => { 28 | let token: string; 29 | 30 | let browser: Browser; 31 | let context: BrowserContext; 32 | let page: Page; 33 | let client: Client | undefined; 34 | 35 | beforeAll(async () => { 36 | browser = await chromium.launch(); 37 | client = clients.find(c => c.ClientId === 'implicit-flow-client-id'); 38 | expect(client).toBeDefined(); 39 | }); 40 | 41 | beforeEach(async () => { 42 | context = await browser.newContext({ ignoreHTTPSErrors: true }); 43 | page = await context.newPage(); 44 | }); 45 | 46 | afterEach(async () => { 47 | await page.close(); 48 | await context.close(); 49 | }); 50 | 51 | afterAll(async () => { 52 | await browser.close(); 53 | }); 54 | 55 | describe.each(testCases)('- %s -', (user: User) => { 56 | test('Authorization Endpoint', async () => { 57 | const parameters = new URLSearchParams({ 58 | client_id: client.ClientId, 59 | scope: client.AllowedScopes.join(' '), 60 | response_type: 'id_token token', 61 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 62 | state: 'abc', 63 | nonce: 'xyz', 64 | }); 65 | 66 | const redirectedUrl = await authorizationEndpoint(page, parameters, user, parameters.get('redirect_uri')); 67 | const hash = redirectedUrl.hash.slice(1); 68 | const query = new URLSearchParams(hash); 69 | 70 | const tokenParameter = query.get('access_token'); 71 | expect(typeof tokenParameter).toBe('string'); 72 | token = tokenParameter; 73 | const decodedAccessToken = decodeJWT(token); 74 | expect(decodedAccessToken).toMatchSnapshot(); 75 | }); 76 | 77 | test('UserInfo Endpoint', async () => { 78 | await userInfoEndpoint(token); 79 | }); 80 | 81 | test('Introspection Endpoint', async () => { 82 | await introspectEndpoint(token, 'some-app'); 83 | }); 84 | 85 | test('Authorization Endpoint (id_token only)', async () => { 86 | const parameters = new URLSearchParams({ 87 | client_id: client.ClientId, 88 | scope: 'openid profile email some-custom-identity', 89 | response_type: 'id_token', 90 | redirect_uri: client.RedirectUris?.[0].replace('*', 'www'), 91 | state: 'abc', 92 | nonce: 'xyz', 93 | }); 94 | const redirectedUrl = await authorizationEndpoint(page, parameters, user, parameters.get('redirect_uri')); 95 | const hash = redirectedUrl.hash.slice(1); 96 | const query = new URLSearchParams(hash); 97 | 98 | const tokenParameter = query.get('id_token'); 99 | expect(typeof tokenParameter).toBe('string'); 100 | token = tokenParameter; 101 | const decodedAccessToken = decodeJWT(token); 102 | expect(decodedAccessToken).toMatchSnapshot(); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /e2e/tests/custom-endpoints/user-management.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, beforeAll, expect } from '@jest/globals'; 2 | import Chance from 'chance'; 3 | 4 | import clients from '../../config/clients.json'; 5 | import { introspectEndpoint, tokenEndpoint, userInfoEndpoint } from '../../helpers'; 6 | import { Client, User } from '../../types'; 7 | import { oidcUserManagementUrl } from 'e2e/helpers/endpoints'; 8 | 9 | describe('User management', () => { 10 | const chance = new Chance(); 11 | const subjectId = chance.guid({ version: 4 }); 12 | const firstName = chance.first(); 13 | const lastName = chance.last(); 14 | const username = `${firstName}_${lastName}`; 15 | const password = chance.string({ length: 8 }); 16 | const email = chance.email(); 17 | 18 | let client: Client | undefined; 19 | let token: string; 20 | 21 | beforeAll(() => { 22 | client = clients.find(c => c.ClientId === 'password-flow-client-id'); 23 | }); 24 | 25 | test('Get user from configuration', async () => { 26 | const configUserId = 'user_with_all_claim_types'; 27 | const configUsername = 'user_with_all_claim_types'; 28 | const response = await fetch(`${oidcUserManagementUrl.href}/${configUserId}`); 29 | expect(response.status).toBe(200); 30 | const receivedUser = (await response.json()) as User; 31 | expect(receivedUser).toHaveProperty('username', configUsername); 32 | }); 33 | 34 | test('Create user', async () => { 35 | const user: User = { 36 | SubjectId: subjectId, 37 | Username: username, 38 | Password: password, 39 | Claims: [ 40 | { 41 | Type: 'name', 42 | Value: `${firstName} ${lastName}`, 43 | }, 44 | { 45 | Type: 'email', 46 | Value: email, 47 | }, 48 | { 49 | Type: 'some-app-user-custom-claim', 50 | Value: `${firstName}'s Custom User Claim`, 51 | }, 52 | { 53 | Type: 'some-app-scope-1-custom-user-claim', 54 | Value: `${firstName}'s Scope Custom User Claim`, 55 | }, 56 | { 57 | Type: 'some-custom-identity-user-claim', 58 | Value: `${firstName}'s Custom User Claim`, 59 | }, 60 | ], 61 | }; 62 | const response = await fetch(oidcUserManagementUrl, { 63 | method: 'POST', 64 | body: JSON.stringify(user), 65 | headers: { 'Content-Type': 'application/json' }, 66 | }); 67 | expect(response.status).toBe(200); 68 | const result = (await response.json()) as unknown; 69 | expect(result).toEqual(subjectId); 70 | }); 71 | 72 | test('Get user', async () => { 73 | const response = await fetch(`${oidcUserManagementUrl.href}/${subjectId}`); 74 | expect(response.status).toBe(200); 75 | const receivedUser = (await response.json()) as User; 76 | expect(receivedUser).toHaveProperty('username', username); 77 | expect(receivedUser).toHaveProperty('isActive', true); 78 | }); 79 | 80 | test('Token Endpoint', async () => { 81 | const parameters = new URLSearchParams({ 82 | client_id: client.ClientId, 83 | username: username, 84 | password: password, 85 | grant_type: 'password', 86 | scope: client.AllowedScopes.join(' '), 87 | }); 88 | 89 | token = await tokenEndpoint(parameters, { 90 | payload: { 91 | sub: expect.any(String) as unknown, 92 | ['some-app-user-custom-claim']: expect.any(String) as unknown, 93 | ['some-app-scope-1-custom-user-claim']: expect.any(String) as unknown, 94 | }, 95 | }); 96 | }); 97 | 98 | test('UserInfo Endpoint', async () => { 99 | await userInfoEndpoint(token, { 100 | sub: expect.any(String), 101 | name: expect.any(String), 102 | email: expect.any(String), 103 | ['some-custom-identity-user-claim']: expect.any(String) as unknown, 104 | }); 105 | }, 10000); 106 | 107 | test('Introspection Endpoint', async () => { 108 | await introspectEndpoint(token, 'some-app', { 109 | sub: expect.any(String), 110 | ['some-app-user-custom-claim']: expect.any(String) as unknown, 111 | ['some-app-scope-1-custom-user-claim']: expect.any(String) as unknown, 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /.github/workflows/tag.yaml: -------------------------------------------------------------------------------- 1 | name: Build and push new version 2 | 3 | on: 4 | create: 5 | 6 | env: 7 | TILT_VERSION: 'v0.34.2' 8 | 9 | jobs: 10 | build_push_docker: 11 | name: Build and Push Docker image 12 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Login to GHCR 18 | uses: docker/login-action@v3 19 | with: 20 | registry: ghcr.io 21 | username: ${{ github.actor }} 22 | password: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - id: get_version 25 | name: Format docker image tag 26 | uses: battila7/get-version-action@v2 27 | 28 | - id: repository_owner 29 | name: Format repository owner 30 | uses: ASzc/change-string-case-action@v6 31 | with: 32 | string: ${{ github.repository_owner }} 33 | 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | 37 | - name: Set up Docker Buildx 38 | uses: docker/setup-buildx-action@v3 39 | 40 | - name: Build and export to Docker 41 | uses: docker/build-push-action@v6 42 | with: 43 | load: true 44 | context: ./src 45 | file: ./src/Dockerfile 46 | tags: ghcr.io/${{ steps.repository_owner.outputs.lowercase }}/oidc-server-mock:${{ steps.get_version.outputs.version-without-v }}-test 47 | 48 | - name: Setup Tilt 49 | run: curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/${TILT_VERSION}/scripts/install.sh | bash 50 | 51 | - name: Setup pnpm 52 | uses: pnpm/action-setup@v4 53 | 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version-file: .nvmrc 57 | cache: pnpm 58 | 59 | - name: Run npm install 60 | run: pnpm install --frozen-lockfile 61 | 62 | - name: Install playwright dependencies 63 | run: pnpm --filter e2e exec playwright install --with-deps chromium 64 | 65 | - name: Run Tests 66 | run: pnpm run tilt:ci 67 | env: 68 | IMAGE_TAG: ${{ steps.get_version.outputs.version-without-v }}-test 69 | 70 | - name: Build and push new docker image 71 | uses: docker/build-push-action@v6 72 | with: 73 | push: true 74 | context: ./src 75 | file: ./src/Dockerfile 76 | platforms: linux/amd64,linux/arm64 77 | tags: | 78 | ghcr.io/${{ steps.repository_owner.outputs.lowercase }}/oidc-server-mock:latest 79 | ghcr.io/${{ steps.repository_owner.outputs.lowercase }}/oidc-server-mock:${{ steps.get_version.outputs.version-without-v }} 80 | labels: | 81 | org.opencontainers.image.source=${{ github.event.repository.html_url }} 82 | 83 | build_push_nuget: 84 | name: Build and Push Nuget package 85 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 86 | runs-on: ubuntu-latest 87 | permissions: 88 | packages: write 89 | contents: read 90 | defaults: 91 | run: 92 | working-directory: src 93 | env: 94 | NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 95 | dotnet-version: '8.0' 96 | steps: 97 | - uses: actions/checkout@v4 98 | 99 | - name: Download UI 100 | run: ./getui.sh 101 | 102 | - name: Setup .NET Core SDK ${{ env.dotnet-version }} 103 | uses: actions/setup-dotnet@v4 104 | with: 105 | dotnet-version: ${{ env.dotnet-version }} 106 | source-url: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json 107 | 108 | - name: Install dependencies 109 | run: dotnet restore 110 | 111 | - id: get_version 112 | name: Format nuget package version 113 | uses: battila7/get-version-action@v2 114 | 115 | - name: Build Nuget package 116 | run: | 117 | dotnet pack --no-restore --configuration Release \ 118 | /p:VersionPrefix=${{ steps.get_version.outputs.version-without-v }} \ 119 | /p:RepositoryCommit=${{ github.sha }} 120 | 121 | - name: Push Nuget package 122 | run: dotnet nuget push bin/Release/*.nupkg -k ${{ env.NUGET_AUTH_TOKEN }} 123 | -------------------------------------------------------------------------------- /e2e/tests/flows/__snapshots__/password-flow.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Password Flow - simple_user - Introspection Endpoint 1`] = ` 4 | { 5 | "iss": "https://localhost:8443", 6 | "aud": "some-app", 7 | "amr": "pwd", 8 | "client_id": "password-flow-client-id", 9 | "sub": "simple_user", 10 | "idp": "local", 11 | "active": true, 12 | "scope": "some-app-scope-1" 13 | } 14 | `; 15 | 16 | exports[`Password Flow - simple_user - Token Endpoint 1`] = ` 17 | { 18 | "alg": "RS256", 19 | "typ": "JWT", 20 | "iss": "https://localhost:8443", 21 | "aud": "some-app", 22 | "scope": [ 23 | "email", 24 | "openid", 25 | "profile", 26 | "some-app-scope-1", 27 | "some-custom-identity" 28 | ], 29 | "amr": [ 30 | "pwd" 31 | ], 32 | "client_id": "password-flow-client-id", 33 | "sub": "simple_user", 34 | "idp": "local" 35 | } 36 | `; 37 | 38 | exports[`Password Flow - simple_user - UserInfo Endpoint 1`] = ` 39 | { 40 | "sub": "simple_user", 41 | } 42 | `; 43 | 44 | exports[`Password Flow - user_with_all_claim_types - Introspection Endpoint 1`] = ` 45 | { 46 | "iss": "https://localhost:8443", 47 | "aud": "some-app", 48 | "amr": "pwd", 49 | "client_id": "password-flow-client-id", 50 | "sub": "user_with_all_claim_types", 51 | "idp": "local", 52 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 53 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim", 54 | "active": true, 55 | "scope": "some-app-scope-1" 56 | } 57 | `; 58 | 59 | exports[`Password Flow - user_with_all_claim_types - Token Endpoint 1`] = ` 60 | { 61 | "alg": "RS256", 62 | "typ": "JWT", 63 | "iss": "https://localhost:8443", 64 | "aud": "some-app", 65 | "scope": [ 66 | "email", 67 | "openid", 68 | "profile", 69 | "some-app-scope-1", 70 | "some-custom-identity" 71 | ], 72 | "amr": [ 73 | "pwd" 74 | ], 75 | "client_id": "password-flow-client-id", 76 | "sub": "user_with_all_claim_types", 77 | "idp": "local", 78 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 79 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim" 80 | } 81 | `; 82 | 83 | exports[`Password Flow - user_with_all_claim_types - UserInfo Endpoint 1`] = ` 84 | { 85 | "email": "oliver.hunter@gmail.com", 86 | "name": "Oliver Hunter", 87 | "some-custom-identity-user-claim": "Oliver's Custom User Claim", 88 | "sub": "user_with_all_claim_types", 89 | } 90 | `; 91 | 92 | exports[`Password Flow - user_with_custom_api_resource_claims - Introspection Endpoint 1`] = ` 93 | { 94 | "iss": "https://localhost:8443", 95 | "aud": "some-app", 96 | "amr": "pwd", 97 | "client_id": "password-flow-client-id", 98 | "sub": "user_with_custom_api_resource_claims", 99 | "idp": "local", 100 | "some-app-user-custom-claim": "Sam's Custom User Claim", 101 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim", 102 | "active": true, 103 | "scope": "some-app-scope-1" 104 | } 105 | `; 106 | 107 | exports[`Password Flow - user_with_custom_api_resource_claims - Token Endpoint 1`] = ` 108 | { 109 | "alg": "RS256", 110 | "typ": "JWT", 111 | "iss": "https://localhost:8443", 112 | "aud": "some-app", 113 | "scope": [ 114 | "email", 115 | "openid", 116 | "profile", 117 | "some-app-scope-1", 118 | "some-custom-identity" 119 | ], 120 | "amr": [ 121 | "pwd" 122 | ], 123 | "client_id": "password-flow-client-id", 124 | "sub": "user_with_custom_api_resource_claims", 125 | "idp": "local", 126 | "some-app-user-custom-claim": "Sam's Custom User Claim", 127 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim" 128 | } 129 | `; 130 | 131 | exports[`Password Flow - user_with_custom_api_resource_claims - UserInfo Endpoint 1`] = ` 132 | { 133 | "email": "sam.tailor@gmail.com", 134 | "name": "Sam Tailor", 135 | "sub": "user_with_custom_api_resource_claims", 136 | } 137 | `; 138 | 139 | exports[`Password Flow - user_with_custom_identity_claims - Introspection Endpoint 1`] = ` 140 | { 141 | "iss": "https://localhost:8443", 142 | "aud": "some-app", 143 | "amr": "pwd", 144 | "client_id": "password-flow-client-id", 145 | "sub": "user_with_custom_identity_claims", 146 | "idp": "local", 147 | "active": true, 148 | "scope": "some-app-scope-1" 149 | } 150 | `; 151 | 152 | exports[`Password Flow - user_with_custom_identity_claims - Token Endpoint 1`] = ` 153 | { 154 | "alg": "RS256", 155 | "typ": "JWT", 156 | "iss": "https://localhost:8443", 157 | "aud": "some-app", 158 | "scope": [ 159 | "email", 160 | "openid", 161 | "profile", 162 | "some-app-scope-1", 163 | "some-custom-identity" 164 | ], 165 | "amr": [ 166 | "pwd" 167 | ], 168 | "client_id": "password-flow-client-id", 169 | "sub": "user_with_custom_identity_claims", 170 | "idp": "local" 171 | } 172 | `; 173 | 174 | exports[`Password Flow - user_with_custom_identity_claims - UserInfo Endpoint 1`] = ` 175 | { 176 | "email": "jack.sparrow@gmail.com", 177 | "name": "Jack Sparrow", 178 | "some-custom-identity-user-claim": "Jack's Custom User Claim", 179 | "sub": "user_with_custom_identity_claims", 180 | } 181 | `; 182 | 183 | exports[`Password Flow - user_with_standard_claims - Introspection Endpoint 1`] = ` 184 | { 185 | "iss": "https://localhost:8443", 186 | "aud": "some-app", 187 | "amr": "pwd", 188 | "client_id": "password-flow-client-id", 189 | "sub": "user_with_standard_claims", 190 | "idp": "local", 191 | "active": true, 192 | "scope": "some-app-scope-1" 193 | } 194 | `; 195 | 196 | exports[`Password Flow - user_with_standard_claims - Token Endpoint 1`] = ` 197 | { 198 | "alg": "RS256", 199 | "typ": "JWT", 200 | "iss": "https://localhost:8443", 201 | "aud": "some-app", 202 | "scope": [ 203 | "email", 204 | "openid", 205 | "profile", 206 | "some-app-scope-1", 207 | "some-custom-identity" 208 | ], 209 | "amr": [ 210 | "pwd" 211 | ], 212 | "client_id": "password-flow-client-id", 213 | "sub": "user_with_standard_claims", 214 | "idp": "local" 215 | } 216 | `; 217 | 218 | exports[`Password Flow - user_with_standard_claims - UserInfo Endpoint 1`] = ` 219 | { 220 | "email": "john.smith@gmail.com", 221 | "email_verified": "true", 222 | "name": "John Smith", 223 | "sub": "user_with_standard_claims", 224 | } 225 | `; 226 | -------------------------------------------------------------------------------- /e2e/tests/flows/__snapshots__/authorization-code.e2e-spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Authorization Code Flow - simple_user - Introspection Endpoint 1`] = ` 4 | { 5 | "iss": "https://localhost:8443", 6 | "aud": "some-app", 7 | "amr": "pwd", 8 | "client_id": "authorization-code-client-id", 9 | "sub": "simple_user", 10 | "idp": "local", 11 | "active": true, 12 | "scope": "some-app-scope-1" 13 | } 14 | `; 15 | 16 | exports[`Authorization Code Flow - simple_user - Token Endpoint 1`] = ` 17 | { 18 | "alg": "RS256", 19 | "typ": "JWT", 20 | "iss": "https://localhost:8443", 21 | "aud": "some-app", 22 | "scope": [ 23 | "openid", 24 | "profile", 25 | "email", 26 | "some-custom-identity", 27 | "some-app-scope-1" 28 | ], 29 | "amr": [ 30 | "pwd" 31 | ], 32 | "client_id": "authorization-code-client-id", 33 | "sub": "simple_user", 34 | "idp": "local" 35 | } 36 | `; 37 | 38 | exports[`Authorization Code Flow - simple_user - UserInfo Endpoint 1`] = ` 39 | { 40 | "sub": "simple_user", 41 | } 42 | `; 43 | 44 | exports[`Authorization Code Flow - user_with_all_claim_types - Introspection Endpoint 1`] = ` 45 | { 46 | "iss": "https://localhost:8443", 47 | "aud": "some-app", 48 | "amr": "pwd", 49 | "client_id": "authorization-code-client-id", 50 | "sub": "user_with_all_claim_types", 51 | "idp": "local", 52 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 53 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim", 54 | "active": true, 55 | "scope": "some-app-scope-1" 56 | } 57 | `; 58 | 59 | exports[`Authorization Code Flow - user_with_all_claim_types - Token Endpoint 1`] = ` 60 | { 61 | "alg": "RS256", 62 | "typ": "JWT", 63 | "iss": "https://localhost:8443", 64 | "aud": "some-app", 65 | "scope": [ 66 | "openid", 67 | "profile", 68 | "email", 69 | "some-custom-identity", 70 | "some-app-scope-1" 71 | ], 72 | "amr": [ 73 | "pwd" 74 | ], 75 | "client_id": "authorization-code-client-id", 76 | "sub": "user_with_all_claim_types", 77 | "idp": "local", 78 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 79 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim" 80 | } 81 | `; 82 | 83 | exports[`Authorization Code Flow - user_with_all_claim_types - UserInfo Endpoint 1`] = ` 84 | { 85 | "email": "oliver.hunter@gmail.com", 86 | "name": "Oliver Hunter", 87 | "some-custom-identity-user-claim": "Oliver's Custom User Claim", 88 | "sub": "user_with_all_claim_types", 89 | } 90 | `; 91 | 92 | exports[`Authorization Code Flow - user_with_custom_api_resource_claims - Introspection Endpoint 1`] = ` 93 | { 94 | "iss": "https://localhost:8443", 95 | "aud": "some-app", 96 | "amr": "pwd", 97 | "client_id": "authorization-code-client-id", 98 | "sub": "user_with_custom_api_resource_claims", 99 | "idp": "local", 100 | "some-app-user-custom-claim": "Sam's Custom User Claim", 101 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim", 102 | "active": true, 103 | "scope": "some-app-scope-1" 104 | } 105 | `; 106 | 107 | exports[`Authorization Code Flow - user_with_custom_api_resource_claims - Token Endpoint 1`] = ` 108 | { 109 | "alg": "RS256", 110 | "typ": "JWT", 111 | "iss": "https://localhost:8443", 112 | "aud": "some-app", 113 | "scope": [ 114 | "openid", 115 | "profile", 116 | "email", 117 | "some-custom-identity", 118 | "some-app-scope-1" 119 | ], 120 | "amr": [ 121 | "pwd" 122 | ], 123 | "client_id": "authorization-code-client-id", 124 | "sub": "user_with_custom_api_resource_claims", 125 | "idp": "local", 126 | "some-app-user-custom-claim": "Sam's Custom User Claim", 127 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim" 128 | } 129 | `; 130 | 131 | exports[`Authorization Code Flow - user_with_custom_api_resource_claims - UserInfo Endpoint 1`] = ` 132 | { 133 | "email": "sam.tailor@gmail.com", 134 | "name": "Sam Tailor", 135 | "sub": "user_with_custom_api_resource_claims", 136 | } 137 | `; 138 | 139 | exports[`Authorization Code Flow - user_with_custom_identity_claims - Introspection Endpoint 1`] = ` 140 | { 141 | "iss": "https://localhost:8443", 142 | "aud": "some-app", 143 | "amr": "pwd", 144 | "client_id": "authorization-code-client-id", 145 | "sub": "user_with_custom_identity_claims", 146 | "idp": "local", 147 | "active": true, 148 | "scope": "some-app-scope-1" 149 | } 150 | `; 151 | 152 | exports[`Authorization Code Flow - user_with_custom_identity_claims - Token Endpoint 1`] = ` 153 | { 154 | "alg": "RS256", 155 | "typ": "JWT", 156 | "iss": "https://localhost:8443", 157 | "aud": "some-app", 158 | "scope": [ 159 | "openid", 160 | "profile", 161 | "email", 162 | "some-custom-identity", 163 | "some-app-scope-1" 164 | ], 165 | "amr": [ 166 | "pwd" 167 | ], 168 | "client_id": "authorization-code-client-id", 169 | "sub": "user_with_custom_identity_claims", 170 | "idp": "local" 171 | } 172 | `; 173 | 174 | exports[`Authorization Code Flow - user_with_custom_identity_claims - UserInfo Endpoint 1`] = ` 175 | { 176 | "email": "jack.sparrow@gmail.com", 177 | "name": "Jack Sparrow", 178 | "some-custom-identity-user-claim": "Jack's Custom User Claim", 179 | "sub": "user_with_custom_identity_claims", 180 | } 181 | `; 182 | 183 | exports[`Authorization Code Flow - user_with_standard_claims - Introspection Endpoint 1`] = ` 184 | { 185 | "iss": "https://localhost:8443", 186 | "aud": "some-app", 187 | "amr": "pwd", 188 | "client_id": "authorization-code-client-id", 189 | "sub": "user_with_standard_claims", 190 | "idp": "local", 191 | "active": true, 192 | "scope": "some-app-scope-1" 193 | } 194 | `; 195 | 196 | exports[`Authorization Code Flow - user_with_standard_claims - Token Endpoint 1`] = ` 197 | { 198 | "alg": "RS256", 199 | "typ": "JWT", 200 | "iss": "https://localhost:8443", 201 | "aud": "some-app", 202 | "scope": [ 203 | "openid", 204 | "profile", 205 | "email", 206 | "some-custom-identity", 207 | "some-app-scope-1" 208 | ], 209 | "amr": [ 210 | "pwd" 211 | ], 212 | "client_id": "authorization-code-client-id", 213 | "sub": "user_with_standard_claims", 214 | "idp": "local" 215 | } 216 | `; 217 | 218 | exports[`Authorization Code Flow - user_with_standard_claims - UserInfo Endpoint 1`] = ` 219 | { 220 | "email": "john.smith@gmail.com", 221 | "email_verified": "true", 222 | "name": "John Smith", 223 | "sub": "user_with_standard_claims", 224 | } 225 | `; 226 | -------------------------------------------------------------------------------- /e2e/tests/flows/__snapshots__/authorization-code-pkce.e2e-spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Authorization Code Flow (with PKCE) - simple_user - Introspection Endpoint 1`] = ` 4 | { 5 | "iss": "https://localhost:8443", 6 | "aud": "some-app", 7 | "amr": "pwd", 8 | "client_id": "authorization-code-with-pkce-client-id", 9 | "sub": "simple_user", 10 | "idp": "local", 11 | "active": true, 12 | "scope": "some-app-scope-1" 13 | } 14 | `; 15 | 16 | exports[`Authorization Code Flow (with PKCE) - simple_user - Token Endpoint 1`] = ` 17 | { 18 | "alg": "RS256", 19 | "typ": "JWT", 20 | "iss": "https://localhost:8443", 21 | "aud": "some-app", 22 | "scope": [ 23 | "openid", 24 | "profile", 25 | "email", 26 | "some-custom-identity", 27 | "some-app-scope-1" 28 | ], 29 | "amr": [ 30 | "pwd" 31 | ], 32 | "client_id": "authorization-code-with-pkce-client-id", 33 | "sub": "simple_user", 34 | "idp": "local" 35 | } 36 | `; 37 | 38 | exports[`Authorization Code Flow (with PKCE) - simple_user - UserInfo Endpoint 1`] = ` 39 | { 40 | "sub": "simple_user", 41 | } 42 | `; 43 | 44 | exports[`Authorization Code Flow (with PKCE) - user_with_all_claim_types - Introspection Endpoint 1`] = ` 45 | { 46 | "iss": "https://localhost:8443", 47 | "aud": "some-app", 48 | "amr": "pwd", 49 | "client_id": "authorization-code-with-pkce-client-id", 50 | "sub": "user_with_all_claim_types", 51 | "idp": "local", 52 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 53 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim", 54 | "active": true, 55 | "scope": "some-app-scope-1" 56 | } 57 | `; 58 | 59 | exports[`Authorization Code Flow (with PKCE) - user_with_all_claim_types - Token Endpoint 1`] = ` 60 | { 61 | "alg": "RS256", 62 | "typ": "JWT", 63 | "iss": "https://localhost:8443", 64 | "aud": "some-app", 65 | "scope": [ 66 | "openid", 67 | "profile", 68 | "email", 69 | "some-custom-identity", 70 | "some-app-scope-1" 71 | ], 72 | "amr": [ 73 | "pwd" 74 | ], 75 | "client_id": "authorization-code-with-pkce-client-id", 76 | "sub": "user_with_all_claim_types", 77 | "idp": "local", 78 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 79 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim" 80 | } 81 | `; 82 | 83 | exports[`Authorization Code Flow (with PKCE) - user_with_all_claim_types - UserInfo Endpoint 1`] = ` 84 | { 85 | "email": "oliver.hunter@gmail.com", 86 | "name": "Oliver Hunter", 87 | "some-custom-identity-user-claim": "Oliver's Custom User Claim", 88 | "sub": "user_with_all_claim_types", 89 | } 90 | `; 91 | 92 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_api_resource_claims - Introspection Endpoint 1`] = ` 93 | { 94 | "iss": "https://localhost:8443", 95 | "aud": "some-app", 96 | "amr": "pwd", 97 | "client_id": "authorization-code-with-pkce-client-id", 98 | "sub": "user_with_custom_api_resource_claims", 99 | "idp": "local", 100 | "some-app-user-custom-claim": "Sam's Custom User Claim", 101 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim", 102 | "active": true, 103 | "scope": "some-app-scope-1" 104 | } 105 | `; 106 | 107 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_api_resource_claims - Token Endpoint 1`] = ` 108 | { 109 | "alg": "RS256", 110 | "typ": "JWT", 111 | "iss": "https://localhost:8443", 112 | "aud": "some-app", 113 | "scope": [ 114 | "openid", 115 | "profile", 116 | "email", 117 | "some-custom-identity", 118 | "some-app-scope-1" 119 | ], 120 | "amr": [ 121 | "pwd" 122 | ], 123 | "client_id": "authorization-code-with-pkce-client-id", 124 | "sub": "user_with_custom_api_resource_claims", 125 | "idp": "local", 126 | "some-app-user-custom-claim": "Sam's Custom User Claim", 127 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim" 128 | } 129 | `; 130 | 131 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_api_resource_claims - UserInfo Endpoint 1`] = ` 132 | { 133 | "email": "sam.tailor@gmail.com", 134 | "name": "Sam Tailor", 135 | "sub": "user_with_custom_api_resource_claims", 136 | } 137 | `; 138 | 139 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_identity_claims - Introspection Endpoint 1`] = ` 140 | { 141 | "iss": "https://localhost:8443", 142 | "aud": "some-app", 143 | "amr": "pwd", 144 | "client_id": "authorization-code-with-pkce-client-id", 145 | "sub": "user_with_custom_identity_claims", 146 | "idp": "local", 147 | "active": true, 148 | "scope": "some-app-scope-1" 149 | } 150 | `; 151 | 152 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_identity_claims - Token Endpoint 1`] = ` 153 | { 154 | "alg": "RS256", 155 | "typ": "JWT", 156 | "iss": "https://localhost:8443", 157 | "aud": "some-app", 158 | "scope": [ 159 | "openid", 160 | "profile", 161 | "email", 162 | "some-custom-identity", 163 | "some-app-scope-1" 164 | ], 165 | "amr": [ 166 | "pwd" 167 | ], 168 | "client_id": "authorization-code-with-pkce-client-id", 169 | "sub": "user_with_custom_identity_claims", 170 | "idp": "local" 171 | } 172 | `; 173 | 174 | exports[`Authorization Code Flow (with PKCE) - user_with_custom_identity_claims - UserInfo Endpoint 1`] = ` 175 | { 176 | "email": "jack.sparrow@gmail.com", 177 | "name": "Jack Sparrow", 178 | "some-custom-identity-user-claim": "Jack's Custom User Claim", 179 | "sub": "user_with_custom_identity_claims", 180 | } 181 | `; 182 | 183 | exports[`Authorization Code Flow (with PKCE) - user_with_standard_claims - Introspection Endpoint 1`] = ` 184 | { 185 | "iss": "https://localhost:8443", 186 | "aud": "some-app", 187 | "amr": "pwd", 188 | "client_id": "authorization-code-with-pkce-client-id", 189 | "sub": "user_with_standard_claims", 190 | "idp": "local", 191 | "active": true, 192 | "scope": "some-app-scope-1" 193 | } 194 | `; 195 | 196 | exports[`Authorization Code Flow (with PKCE) - user_with_standard_claims - Token Endpoint 1`] = ` 197 | { 198 | "alg": "RS256", 199 | "typ": "JWT", 200 | "iss": "https://localhost:8443", 201 | "aud": "some-app", 202 | "scope": [ 203 | "openid", 204 | "profile", 205 | "email", 206 | "some-custom-identity", 207 | "some-app-scope-1" 208 | ], 209 | "amr": [ 210 | "pwd" 211 | ], 212 | "client_id": "authorization-code-with-pkce-client-id", 213 | "sub": "user_with_standard_claims", 214 | "idp": "local" 215 | } 216 | `; 217 | 218 | exports[`Authorization Code Flow (with PKCE) - user_with_standard_claims - UserInfo Endpoint 1`] = ` 219 | { 220 | "email": "john.smith@gmail.com", 221 | "email_verified": "true", 222 | "name": "John Smith", 223 | "sub": "user_with_standard_claims", 224 | } 225 | `; 226 | -------------------------------------------------------------------------------- /e2e/tests/flows/__snapshots__/implicit-flow.e2e-spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Implicit Flow - simple_user - Authorization Endpoint (id_token only) 1`] = ` 4 | { 5 | "alg": "RS256", 6 | "typ": "JWT", 7 | "iss": "https://localhost:8443", 8 | "aud": "implicit-flow-client-id", 9 | "amr": [ 10 | "pwd" 11 | ], 12 | "nonce": "xyz", 13 | "sub": "simple_user", 14 | "idp": "local" 15 | } 16 | `; 17 | 18 | exports[`Implicit Flow - simple_user - Authorization Endpoint 1`] = ` 19 | { 20 | "alg": "RS256", 21 | "typ": "JWT", 22 | "iss": "https://localhost:8443", 23 | "aud": "some-app", 24 | "scope": [ 25 | "openid", 26 | "profile", 27 | "email", 28 | "some-custom-identity", 29 | "some-app-scope-1" 30 | ], 31 | "amr": [ 32 | "pwd" 33 | ], 34 | "client_id": "implicit-flow-client-id", 35 | "sub": "simple_user", 36 | "idp": "local" 37 | } 38 | `; 39 | 40 | exports[`Implicit Flow - simple_user - Introspection Endpoint 1`] = ` 41 | { 42 | "iss": "https://localhost:8443", 43 | "aud": "some-app", 44 | "amr": "pwd", 45 | "client_id": "implicit-flow-client-id", 46 | "sub": "simple_user", 47 | "idp": "local", 48 | "active": true, 49 | "scope": "some-app-scope-1" 50 | } 51 | `; 52 | 53 | exports[`Implicit Flow - simple_user - UserInfo Endpoint 1`] = ` 54 | { 55 | "sub": "simple_user", 56 | } 57 | `; 58 | 59 | exports[`Implicit Flow - user_with_all_claim_types - Authorization Endpoint (id_token only) 1`] = ` 60 | { 61 | "alg": "RS256", 62 | "typ": "JWT", 63 | "iss": "https://localhost:8443", 64 | "aud": "implicit-flow-client-id", 65 | "amr": [ 66 | "pwd" 67 | ], 68 | "nonce": "xyz", 69 | "sub": "user_with_all_claim_types", 70 | "idp": "local", 71 | "name": "Oliver Hunter", 72 | "email": "oliver.hunter@gmail.com", 73 | "some-custom-identity-user-claim": "Oliver's Custom User Claim" 74 | } 75 | `; 76 | 77 | exports[`Implicit Flow - user_with_all_claim_types - Authorization Endpoint 1`] = ` 78 | { 79 | "alg": "RS256", 80 | "typ": "JWT", 81 | "iss": "https://localhost:8443", 82 | "aud": "some-app", 83 | "scope": [ 84 | "openid", 85 | "profile", 86 | "email", 87 | "some-custom-identity", 88 | "some-app-scope-1" 89 | ], 90 | "amr": [ 91 | "pwd" 92 | ], 93 | "client_id": "implicit-flow-client-id", 94 | "sub": "user_with_all_claim_types", 95 | "idp": "local", 96 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 97 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim" 98 | } 99 | `; 100 | 101 | exports[`Implicit Flow - user_with_all_claim_types - Introspection Endpoint 1`] = ` 102 | { 103 | "iss": "https://localhost:8443", 104 | "aud": "some-app", 105 | "amr": "pwd", 106 | "client_id": "implicit-flow-client-id", 107 | "sub": "user_with_all_claim_types", 108 | "idp": "local", 109 | "some-app-user-custom-claim": "Oliver's Custom User Claim", 110 | "some-app-scope-1-custom-user-claim": "Oliver's Scope Custom User Claim", 111 | "active": true, 112 | "scope": "some-app-scope-1" 113 | } 114 | `; 115 | 116 | exports[`Implicit Flow - user_with_all_claim_types - UserInfo Endpoint 1`] = ` 117 | { 118 | "email": "oliver.hunter@gmail.com", 119 | "name": "Oliver Hunter", 120 | "some-custom-identity-user-claim": "Oliver's Custom User Claim", 121 | "sub": "user_with_all_claim_types", 122 | } 123 | `; 124 | 125 | exports[`Implicit Flow - user_with_custom_api_resource_claims - Authorization Endpoint (id_token only) 1`] = ` 126 | { 127 | "alg": "RS256", 128 | "typ": "JWT", 129 | "iss": "https://localhost:8443", 130 | "aud": "implicit-flow-client-id", 131 | "amr": [ 132 | "pwd" 133 | ], 134 | "nonce": "xyz", 135 | "sub": "user_with_custom_api_resource_claims", 136 | "idp": "local", 137 | "name": "Sam Tailor", 138 | "email": "sam.tailor@gmail.com" 139 | } 140 | `; 141 | 142 | exports[`Implicit Flow - user_with_custom_api_resource_claims - Authorization Endpoint 1`] = ` 143 | { 144 | "alg": "RS256", 145 | "typ": "JWT", 146 | "iss": "https://localhost:8443", 147 | "aud": "some-app", 148 | "scope": [ 149 | "openid", 150 | "profile", 151 | "email", 152 | "some-custom-identity", 153 | "some-app-scope-1" 154 | ], 155 | "amr": [ 156 | "pwd" 157 | ], 158 | "client_id": "implicit-flow-client-id", 159 | "sub": "user_with_custom_api_resource_claims", 160 | "idp": "local", 161 | "some-app-user-custom-claim": "Sam's Custom User Claim", 162 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim" 163 | } 164 | `; 165 | 166 | exports[`Implicit Flow - user_with_custom_api_resource_claims - Introspection Endpoint 1`] = ` 167 | { 168 | "iss": "https://localhost:8443", 169 | "aud": "some-app", 170 | "amr": "pwd", 171 | "client_id": "implicit-flow-client-id", 172 | "sub": "user_with_custom_api_resource_claims", 173 | "idp": "local", 174 | "some-app-user-custom-claim": "Sam's Custom User Claim", 175 | "some-app-scope-1-custom-user-claim": "Sam's Scope Custom User Claim", 176 | "active": true, 177 | "scope": "some-app-scope-1" 178 | } 179 | `; 180 | 181 | exports[`Implicit Flow - user_with_custom_api_resource_claims - UserInfo Endpoint 1`] = ` 182 | { 183 | "email": "sam.tailor@gmail.com", 184 | "name": "Sam Tailor", 185 | "sub": "user_with_custom_api_resource_claims", 186 | } 187 | `; 188 | 189 | exports[`Implicit Flow - user_with_custom_identity_claims - Authorization Endpoint (id_token only) 1`] = ` 190 | { 191 | "alg": "RS256", 192 | "typ": "JWT", 193 | "iss": "https://localhost:8443", 194 | "aud": "implicit-flow-client-id", 195 | "amr": [ 196 | "pwd" 197 | ], 198 | "nonce": "xyz", 199 | "sub": "user_with_custom_identity_claims", 200 | "idp": "local", 201 | "name": "Jack Sparrow", 202 | "email": "jack.sparrow@gmail.com", 203 | "some-custom-identity-user-claim": "Jack's Custom User Claim" 204 | } 205 | `; 206 | 207 | exports[`Implicit Flow - user_with_custom_identity_claims - Authorization Endpoint 1`] = ` 208 | { 209 | "alg": "RS256", 210 | "typ": "JWT", 211 | "iss": "https://localhost:8443", 212 | "aud": "some-app", 213 | "scope": [ 214 | "openid", 215 | "profile", 216 | "email", 217 | "some-custom-identity", 218 | "some-app-scope-1" 219 | ], 220 | "amr": [ 221 | "pwd" 222 | ], 223 | "client_id": "implicit-flow-client-id", 224 | "sub": "user_with_custom_identity_claims", 225 | "idp": "local" 226 | } 227 | `; 228 | 229 | exports[`Implicit Flow - user_with_custom_identity_claims - Introspection Endpoint 1`] = ` 230 | { 231 | "iss": "https://localhost:8443", 232 | "aud": "some-app", 233 | "amr": "pwd", 234 | "client_id": "implicit-flow-client-id", 235 | "sub": "user_with_custom_identity_claims", 236 | "idp": "local", 237 | "active": true, 238 | "scope": "some-app-scope-1" 239 | } 240 | `; 241 | 242 | exports[`Implicit Flow - user_with_custom_identity_claims - UserInfo Endpoint 1`] = ` 243 | { 244 | "email": "jack.sparrow@gmail.com", 245 | "name": "Jack Sparrow", 246 | "some-custom-identity-user-claim": "Jack's Custom User Claim", 247 | "sub": "user_with_custom_identity_claims", 248 | } 249 | `; 250 | 251 | exports[`Implicit Flow - user_with_standard_claims - Authorization Endpoint (id_token only) 1`] = ` 252 | { 253 | "alg": "RS256", 254 | "typ": "JWT", 255 | "iss": "https://localhost:8443", 256 | "aud": "implicit-flow-client-id", 257 | "amr": [ 258 | "pwd" 259 | ], 260 | "nonce": "xyz", 261 | "sub": "user_with_standard_claims", 262 | "idp": "local", 263 | "name": "John Smith", 264 | "email": "john.smith@gmail.com", 265 | "email_verified": "true" 266 | } 267 | `; 268 | 269 | exports[`Implicit Flow - user_with_standard_claims - Authorization Endpoint 1`] = ` 270 | { 271 | "alg": "RS256", 272 | "typ": "JWT", 273 | "iss": "https://localhost:8443", 274 | "aud": "some-app", 275 | "scope": [ 276 | "openid", 277 | "profile", 278 | "email", 279 | "some-custom-identity", 280 | "some-app-scope-1" 281 | ], 282 | "amr": [ 283 | "pwd" 284 | ], 285 | "client_id": "implicit-flow-client-id", 286 | "sub": "user_with_standard_claims", 287 | "idp": "local" 288 | } 289 | `; 290 | 291 | exports[`Implicit Flow - user_with_standard_claims - Introspection Endpoint 1`] = ` 292 | { 293 | "iss": "https://localhost:8443", 294 | "aud": "some-app", 295 | "amr": "pwd", 296 | "client_id": "implicit-flow-client-id", 297 | "sub": "user_with_standard_claims", 298 | "idp": "local", 299 | "active": true, 300 | "scope": "some-app-scope-1" 301 | } 302 | `; 303 | 304 | exports[`Implicit Flow - user_with_standard_claims - UserInfo Endpoint 1`] = ` 305 | { 306 | "email": "john.smith@gmail.com", 307 | "email_verified": "true", 308 | "name": "John Smith", 309 | "sub": "user_with_standard_claims", 310 | } 311 | `; 312 | -------------------------------------------------------------------------------- /src/Config.cs: -------------------------------------------------------------------------------- 1 | using Duende.IdentityServer.Configuration; 2 | using Duende.IdentityServer.Models; 3 | using Duende.IdentityServer.Test; 4 | using OpenIdConnectServer.Helpers; 5 | using OpenIdConnectServer.YamlConverters; 6 | using YamlDotNet.Serialization; 7 | 8 | namespace OpenIdConnectServer 9 | { 10 | public static class Config 11 | { 12 | public static AspNetServicesOptions GetAspNetServicesOptions() 13 | { 14 | string aspNetServicesOptionsStr = Environment.GetEnvironmentVariable("ASPNET_SERVICES_OPTIONS_INLINE"); 15 | if (string.IsNullOrWhiteSpace(aspNetServicesOptionsStr)) 16 | { 17 | var aspNetServicesOptionsPath = Environment.GetEnvironmentVariable("ASPNET_SERVICES_OPTIONS_PATH"); 18 | if (string.IsNullOrWhiteSpace(aspNetServicesOptionsPath)) 19 | { 20 | return new AspNetServicesOptions(); 21 | } 22 | aspNetServicesOptionsStr = File.ReadAllText(aspNetServicesOptionsPath); 23 | } 24 | var aspNetServicesOptions = DeserializeObject(aspNetServicesOptionsStr); 25 | return aspNetServicesOptions; 26 | } 27 | 28 | public static IdentityServerOptions GetServerOptions() 29 | { 30 | string serverOptionsStr = Environment.GetEnvironmentVariable("SERVER_OPTIONS_INLINE"); 31 | if (string.IsNullOrWhiteSpace(serverOptionsStr)) 32 | { 33 | var serverOptionsFilePath = Environment.GetEnvironmentVariable("SERVER_OPTIONS_PATH"); 34 | if (string.IsNullOrWhiteSpace(serverOptionsFilePath)) 35 | { 36 | return new IdentityServerOptions(); 37 | } 38 | serverOptionsStr = File.ReadAllText(serverOptionsFilePath); 39 | } 40 | var serverOptions = DeserializeObject(serverOptionsStr); 41 | return serverOptions; 42 | } 43 | 44 | public static void ConfigureOptions(string optionsName) 45 | { 46 | string optionsStr = Environment.GetEnvironmentVariable($"{optionsName.ToUpper()}_OPTIONS_INLINE"); 47 | if (string.IsNullOrWhiteSpace(optionsStr)) 48 | { 49 | var optionsFilePath = Environment.GetEnvironmentVariable($"{optionsName.ToUpper()}_OPTIONS_PATH"); 50 | if (string.IsNullOrWhiteSpace(optionsFilePath)) 51 | { 52 | return; 53 | } 54 | optionsStr = File.ReadAllText(optionsFilePath); 55 | } 56 | OptionsHelper.ConfigureOptions(optionsStr); 57 | } 58 | 59 | public static IEnumerable GetServerCorsAllowedOrigins() 60 | { 61 | string allowedOriginsStr = Environment.GetEnvironmentVariable("SERVER_CORS_ALLOWED_ORIGINS_INLINE"); 62 | if (string.IsNullOrWhiteSpace(allowedOriginsStr)) 63 | { 64 | var allowedOriginsFilePath = Environment.GetEnvironmentVariable("SERVER_CORS_ALLOWED_ORIGINS_PATH"); 65 | if (string.IsNullOrWhiteSpace(allowedOriginsFilePath)) 66 | { 67 | return null; 68 | } 69 | allowedOriginsStr = File.ReadAllText(allowedOriginsFilePath); 70 | } 71 | var allowedOrigins = DeserializeObject>(allowedOriginsStr); 72 | return allowedOrigins; 73 | } 74 | 75 | public static IEnumerable GetApiScopes() 76 | { 77 | string apiScopesStr = Environment.GetEnvironmentVariable("API_SCOPES_INLINE"); 78 | if (string.IsNullOrWhiteSpace(apiScopesStr)) 79 | { 80 | var apiScopesFilePath = Environment.GetEnvironmentVariable("API_SCOPES_PATH"); 81 | if (string.IsNullOrWhiteSpace(apiScopesFilePath)) 82 | { 83 | return new List(); 84 | } 85 | apiScopesStr = File.ReadAllText(apiScopesFilePath); 86 | } 87 | var apiScopes = DeserializeObject>(apiScopesStr); 88 | return apiScopes; 89 | } 90 | 91 | public static IEnumerable GetApiResources() 92 | { 93 | string apiResourcesStr = Environment.GetEnvironmentVariable("API_RESOURCES_INLINE"); 94 | if (string.IsNullOrWhiteSpace(apiResourcesStr)) 95 | { 96 | var apiResourcesFilePath = Environment.GetEnvironmentVariable("API_RESOURCES_PATH"); 97 | if (string.IsNullOrWhiteSpace(apiResourcesFilePath)) 98 | { 99 | return new List(); 100 | } 101 | apiResourcesStr = File.ReadAllText(apiResourcesFilePath); 102 | } 103 | var apiResources = DeserializeObject>(apiResourcesStr); 104 | return apiResources; 105 | } 106 | 107 | public static IEnumerable GetClients() 108 | { 109 | string configStr = Environment.GetEnvironmentVariable("CLIENTS_CONFIGURATION_INLINE"); 110 | if (string.IsNullOrWhiteSpace(configStr)) 111 | { 112 | var configFilePath = Environment.GetEnvironmentVariable("CLIENTS_CONFIGURATION_PATH"); 113 | if (string.IsNullOrWhiteSpace(configFilePath)) 114 | { 115 | throw new ArgumentNullException("You must set either CLIENTS_CONFIGURATION_INLINE or CLIENTS_CONFIGURATION_PATH env variable"); 116 | } 117 | configStr = File.ReadAllText(configFilePath); 118 | } 119 | var configClients = DeserializeObject>(configStr); 120 | return configClients; 121 | } 122 | 123 | public static IEnumerable GetIdentityResources() 124 | { 125 | IEnumerable identityResources = new List(); 126 | var overrideStandardResources = Environment.GetEnvironmentVariable("OVERRIDE_STANDARD_IDENTITY_RESOURCES"); 127 | if (string.IsNullOrEmpty(overrideStandardResources) || Boolean.Parse(overrideStandardResources) != true) 128 | { 129 | var standardResources = new List 130 | { 131 | new IdentityResources.OpenId(), 132 | new IdentityResources.Profile(), 133 | new IdentityResources.Email() 134 | }; 135 | identityResources = identityResources.Union(standardResources); 136 | } 137 | return identityResources.Union(GetCustomIdentityResources()); 138 | } 139 | 140 | public static List GetUsers() 141 | { 142 | string configStr = Environment.GetEnvironmentVariable("USERS_CONFIGURATION_INLINE"); 143 | if (string.IsNullOrWhiteSpace(configStr)) 144 | { 145 | var configFilePath = Environment.GetEnvironmentVariable("USERS_CONFIGURATION_PATH"); 146 | if (string.IsNullOrWhiteSpace(configFilePath)) 147 | { 148 | return new List(); 149 | } 150 | configStr = File.ReadAllText(configFilePath); 151 | } 152 | var configUsers = DeserializeObject>(configStr); 153 | return configUsers; 154 | } 155 | 156 | private static IEnumerable GetCustomIdentityResources() 157 | { 158 | string identityResourcesStr = Environment.GetEnvironmentVariable("IDENTITY_RESOURCES_INLINE"); 159 | if (string.IsNullOrWhiteSpace(identityResourcesStr)) 160 | { 161 | var identityResourcesFilePath = Environment.GetEnvironmentVariable("IDENTITY_RESOURCES_PATH"); 162 | if (string.IsNullOrWhiteSpace(identityResourcesFilePath)) 163 | { 164 | return new List(); 165 | } 166 | identityResourcesStr = File.ReadAllText(identityResourcesFilePath); 167 | } 168 | 169 | var identityResourceConfig = DeserializeObject(identityResourcesStr); 170 | return identityResourceConfig.Select(c => new IdentityResource(c.Name, c.ClaimTypes)); 171 | } 172 | 173 | private static T DeserializeObject(string value) 174 | { 175 | var deserializer = new DeserializerBuilder() 176 | .WithTypeConverter(new ClaimYamlConverter()) 177 | .WithTypeConverter(new SecretYamlConverter()) 178 | .Build(); 179 | return deserializer.Deserialize(value); 180 | } 181 | 182 | private class IdentityResourceConfig 183 | { 184 | public string Name { get; set; } 185 | public IEnumerable ClaimTypes { get; set; } 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenId Connect Server Mock 2 | 3 | ![Run Tests badge](https://github.com/Soluto/oidc-server-mock/workflows/Run%20Tests/badge.svg) 4 | 5 | This project allows you to run configurable mock server with OpenId Connect functionality. 6 | 7 | ## Important 8 | 9 | > Free for development, testing and personal projects. For production you need to purchase [Duende IdentityServer license](https://duendesoftware.com/products/identityserver). 10 | 11 | ## Simple Configuration 12 | 13 | The image is stored in `github` registry. Use the following to pull the image: 14 | 15 | ```bash 16 | docker pull ghcr.io/soluto/oidc-server-mock:latest 17 | ``` 18 | 19 | This is the sample of using the server in `docker-compose` configuration: 20 | 21 | ```yaml 22 | services: 23 | oidc-server-mock: 24 | container_name: oidc-server-mock 25 | image: ghcr.io/soluto/oidc-server-mock:latest 26 | ports: 27 | - '4011:80' 28 | environment: 29 | ASPNETCORE_ENVIRONMENT: Development 30 | SERVER_OPTIONS_INLINE: | 31 | { 32 | "AccessTokenJwtType": "JWT", 33 | "Discovery": { 34 | "ShowKeySet": true 35 | }, 36 | "Authentication": { 37 | "CookieSameSiteMode": "Lax", 38 | "CheckSessionCookieSameSiteMode": "Lax" 39 | } 40 | } 41 | LOGIN_OPTIONS_INLINE: | 42 | { 43 | "AllowRememberLogin": false 44 | } 45 | LOGOUT_OPTIONS_INLINE: | 46 | { 47 | "AutomaticRedirectAfterSignOut": true 48 | } 49 | API_SCOPES_INLINE: | 50 | - Name: some-app-scope-1 51 | - Name: some-app-scope-2 52 | API_RESOURCES_INLINE: | 53 | - Name: some-app 54 | Scopes: 55 | - some-app-scope-1 56 | - some-app-scope-2 57 | USERS_CONFIGURATION_INLINE: | 58 | [ 59 | { 60 | "SubjectId":"1", 61 | "Username":"User1", 62 | "Password":"pwd", 63 | "Claims": [ 64 | { 65 | "Type": "name", 66 | "Value": "Sam Tailor", 67 | "ValueType": "string" 68 | }, 69 | { 70 | "Type": "email", 71 | "Value": "sam.tailor@gmail.com", 72 | "ValueType": "string" 73 | }, 74 | { 75 | "Type": "some-api-resource-claim", 76 | "Value": "Sam's Api Resource Custom Claim", 77 | "ValueType": "string" 78 | }, 79 | { 80 | "Type": "some-api-scope-claim", 81 | "Value": "Sam's Api Scope Custom Claim", 82 | "ValueType": "string" 83 | }, 84 | { 85 | "Type": "some-identity-resource-claim", 86 | "Value": "Sam's Identity Resource Custom Claim", 87 | "ValueType": "string" 88 | } 89 | ] 90 | } 91 | ] 92 | CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json 93 | ASPNET_SERVICES_OPTIONS_INLINE: | 94 | { 95 | "ForwardedHeadersOptions": { 96 | "ForwardedHeaders" : "All" 97 | } 98 | } 99 | volumes: 100 | - .:/tmp/config:ro 101 | ``` 102 | 103 | When `clients-config.json` is as following: 104 | 105 | ```json 106 | [ 107 | { 108 | "ClientId": "implicit-mock-client", 109 | "Description": "Client for implicit flow", 110 | "AllowedGrantTypes": ["implicit"], 111 | "AllowAccessTokensViaBrowser": true, 112 | "RedirectUris": ["http://localhost:3000/auth/oidc", "http://localhost:4004/auth/oidc"], 113 | "AllowedScopes": ["openid", "profile", "email"], 114 | "IdentityTokenLifetime": 3600, 115 | "AccessTokenLifetime": 3600 116 | }, 117 | { 118 | "ClientId": "client-credentials-mock-client", 119 | "ClientSecrets": ["client-credentials-mock-client-secret"], 120 | "Description": "Client for client credentials flow", 121 | "AllowedGrantTypes": ["client_credentials"], 122 | "AllowedScopes": ["some-app-scope-1"], 123 | "ClientClaimsPrefix": "", 124 | "Claims": [ 125 | { 126 | "Type": "string_claim", 127 | "Value": "string_claim_value", 128 | "ValueType": "string" 129 | }, 130 | { 131 | "Type": "json_claim", 132 | "Value": "[\"value1\", \"value2\"]", 133 | "ValueType": "json" 134 | } 135 | ] 136 | } 137 | ] 138 | ``` 139 | 140 | This is the sample of using the server in `Dockerfile` configuration: 141 | 142 | ``` 143 | # Use the base image 144 | FROM ghcr.io/soluto/oidc-server-mock:0.8.6 145 | 146 | # Set environment variables 147 | # additional configuration can be found in the readme 148 | # https://github.com/Soluto/oidc-server-mock/blob/master/README.md?plain=1#L145 149 | ENV ASPNETCORE_ENVIRONMENT=Development 150 | ENV SERVER_OPTIONS_INLINE="{ \ 151 | \"AccessTokenJwtType\": \"JWT\", \ 152 | \"Discovery\": { \ 153 | \"ShowKeySet\": true \ 154 | }, \ 155 | \"Authentication\": { \ 156 | \"CookieSameSiteMode\": \"Lax\", \ 157 | \"CheckSessionCookieSameSiteMode\": \"Lax\" \ 158 | } \ 159 | }" 160 | ENV USERS_CONFIGURATION_INLINE="[ \ 161 | { \ 162 | \"SubjectId\": \"1\", \ 163 | \"Username\": \"User1\", \ 164 | \"Password\": \"pwd\", \ 165 | \"Claims\": [ \ 166 | { \ 167 | \"Type\": \"name\", \ 168 | \"Value\": \"Sam Tailor\", \ 169 | \"ValueType\": \"string\" \ 170 | }, \ 171 | { \ 172 | \"Type\": \"email\", \ 173 | \"Value\": \"sam.tailor@gmail.com\", \ 174 | \"ValueType\": \"string\" \ 175 | }, \ 176 | { \ 177 | \"Type\": \"some-api-resource-claim\", \ 178 | \"Value\": \"Sam's Api Resource Custom Claim\", \ 179 | \"ValueType\": \"string\" \ 180 | }, \ 181 | { \ 182 | \"Type\": \"some-api-scope-claim\", \ 183 | \"Value\": \"Sam's Api Scope Custom Claim\", \ 184 | \"ValueType\": \"string\" \ 185 | }, \ 186 | { \ 187 | \"Type\": \"some-identity-resource-claim\", \ 188 | \"Value\": \"Sam's Identity Resource Custom Claim\", \ 189 | \"ValueType\": \"string\" \ 190 | } \ 191 | ] \ 192 | } \ 193 | ]" 194 | ENV CLIENTS_CONFIGURATION_INLINE="[ \ 195 | { \ 196 | \"ClientId\": \"some-client-di\", \ 197 | \"ClientSecrets\": [\"some-client-Secret\"], \ 198 | \"Description\": \"Client for authorization code flow\", \ 199 | \"AllowedGrantTypes\": [\"authorization_code\"], \ 200 | \"RequirePkce\": false, \ 201 | \"AllowAccessTokensViaBrowser\": true, \ 202 | \"RedirectUris\": [\"http://some-callback-url"], \ 203 | \"AllowedScopes\": [\"openid\", \"profile\", \"email\"], \ 204 | \"IdentityTokenLifetime\": 3600, \ 205 | \"AccessTokenLifetime\": 3600, \ 206 | \"RequireClientSecret\": false \ 207 | } \ 208 | ]" 209 | ENV ASPNET_SERVICES_OPTIONS_INLINE="{ \ 210 | \"ForwardedHeadersOptions\": { \ 211 | \"ForwardedHeaders\": \"All\" \ 212 | } \ 213 | }" 214 | 215 | # Expose the port 216 | EXPOSE 80 217 | 218 | # Command to run the application 219 | CMD ["dotnet", "Soluto.OidcServerMock.dll"] 220 | ``` 221 | 222 | Clients configuration should be provided. Test user configuration is optional (used for implicit flow only). 223 | 224 | There are two ways to provide configuration for supported scopes, clients and users. You can either provide it inline as environment variable: 225 | 226 | - `SERVER_OPTIONS_INLINE` 227 | - `LOGIN_OPTIONS_INLINE` 228 | - `LOGOUT_OPTIONS_INLINE` 229 | - `API_SCOPES_INLINE` 230 | - `USERS_CONFIGURATION_INLINE` 231 | - `CLIENTS_CONFIGURATION_INLINE` 232 | - `API_RESOURCES_INLINE` 233 | - `IDENTITY_RESOURCES_INLINE` 234 | 235 | or mount volume and provide the path to configuration json as environment variable: 236 | 237 | - `SERVER_OPTIONS_PATH` 238 | - `LOGIN_OPTIONS_PATH` 239 | - `LOGOUT_OPTIONS_PATH` 240 | - `API_SCOPES_PATH` 241 | - `USERS_CONFIGURATION_PATH` 242 | - `CLIENTS_CONFIGURATION_PATH` 243 | - `API_RESOURCES_PATH` 244 | - `IDENTITY_RESOURCES_PATH` 245 | 246 | The configuration format can be Yaml or JSON both for inline or file path options. 247 | 248 | In order to be able to override standard identity resources set `OVERRIDE_STANDARD_IDENTITY_RESOURCES` env var to `True`. 249 | 250 | ## Base path 251 | 252 | The server can be configured to run with base path. So all the server endpoints will be also available with some prefix segment. 253 | For example `http://localhost:8080/my-base-path/.well-known/openid-configuration` and `http://localhost:8080/my-base-path/connect/token`. 254 | Just set `BasePath` property in `ASPNET_SERVICES_OPTIONS_INLINE/PATH` env var. 255 | 256 | ## Custom endpoints 257 | 258 | ### User management 259 | 260 | Users can be added (in future also removed and altered) via `user management` endpoint. 261 | 262 | - Create new user: `POST` request to `/api/v1/user` path. 263 | The request body should be the `User` object. Just as in `USERS_CONFIGURATION`. 264 | The response is subjectId as sent in request. 265 | 266 | - Get user: `GET` request to `/api/v1/user/{subjectId}` path. 267 | The response is `User` object 268 | 269 | - Update user `PUT` request to `/api/v1/user` path. (**Not implemented yet**) 270 | The request body should be the `User` object. Just as in `USERS_CONFIGURATION`. 271 | The response is subjectId as sent in request. 272 | 273 | > If user doesn't exits it will be created. 274 | 275 | - Delete user: `DELETE` request to `/api/v1/user/{subjectId}` path. (**Not implemented yet**) 276 | The response is `User` object 277 | 278 | ## HTTPS 279 | 280 | To use `https` protocol with the server just add the following environment variables to the `docker run`/`docker-compose up` command, expose ports and mount volume containing the pfx file: 281 | 282 | ```yaml 283 | environment: 284 | ASPNETCORE_URLS: https://+:443;http://+:80 285 | ASPNETCORE_Kestrel__Certificates__Default__Password: 286 | ASPNETCORE_Kestrel__Certificates__Default__Path: /path/to/pfx/file 287 | volumes: 288 | - ./local/path/to/pfx/file:/path/to/pfx/file:ro 289 | ports: 290 | - 8080:80 291 | - 8443:443 292 | ``` 293 | 294 | --- 295 | 296 | ## Cookie SameSite mode 297 | 298 | Since Aug 2020 Chrome has a new [secure-by-default model](https://blog.chromium.org/2019/10/developers-get-ready-for-new.html) for cookies, enabled by a new cookie classification system. Other browsers will join in near future. 299 | 300 | There are two ways to use `oidc-server-mock` with this change. 301 | 302 | 1. Run the container with HTTPS enabled (see above). 303 | 2. Change cookies `SameSite` mode from default `None` to `Lax`. To do so just add the following to `SERVER_OPTIONS_INLINE` (or the file at `SERVER_OPTIONS_PATH`): 304 | 305 | ```javascript 306 | { 307 | // Existing configuration 308 | // ... 309 | "Authentication": { 310 | "CookieSameSiteMode": "Lax", 311 | "CheckSessionCookieSameSiteMode": "Lax" 312 | } 313 | } 314 | ``` 315 | 316 | ## Contributing 317 | 318 | ### Requirements 319 | 320 | 1. [Docker](https://www.docker.com/) (version 18.09 or higher) 321 | 322 | 2. [NodeJS](https://nodejs.org/en/) (version 10.0.0 or higher) 323 | 324 | ### Getting started 325 | 326 | 1. Clone the repo: 327 | 328 | ```sh 329 | git clone git@github.com:Soluto/oidc-server-mock.git 330 | ``` 331 | 332 | 2. Install `npm` packages (run from `/e2e` folder): 333 | 334 | ```sh 335 | npm install 336 | ``` 337 | 338 | > Note: During the build of Docker image UI source code is fetched from [github](https://github.com/IdentityServer/IdentityServer4.Quickstart.UI/tree/main). If you experience some issues on project compile step of Docker build or on runtime try to change the branch or commit in the [script](./src/getmain.sh). 339 | 340 | 3. Run tests: 341 | 342 | ```sh 343 | npm run test 344 | ``` 345 | 346 | ## Used by 347 | 348 | 1. [Tweek](https://github.com/Soluto/tweek) blackbox [tests](https://github.com/Soluto/tweek-blackbox). 349 | 350 | 2. [Stitch](https://github.com/Soluto/Stitch) e2e tests. 351 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------