@specific.dev/spectest 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,235 @@
1
+ // Shared runtime behind the third-party provider components — `google()`,
2
+ // `github()`, `apple()`, `microsoft()`, `okta()`.
3
+ //
4
+ // Each of those is one container answering at that provider's **real**
5
+ // endpoints: the component claims the provider's domains, so DNS, a leaf
6
+ // certificate from the in-VM root CA, and a reverse proxy all point at it.
7
+ // The app under test keeps its production configuration and never learns it
8
+ // is under test.
9
+ //
10
+ // A provider component is therefore a table, not a program. It names the
11
+ // hosts it claims, the emulator package behind it, the handful of paths
12
+ // where that emulator differs from the real service, and how to seed it.
13
+ // Everything else lives here and in `entry.mjs`.
14
+ //
15
+ // ── Internal notes (deliberately not in the user-facing docs) ─────────────
16
+ //
17
+ // The emulators come from vercel-labs/emulate (Apache-2.0), used through
18
+ // their published `@emulators/*` packages and pinned. They are NOT SDK
19
+ // dependencies: they install into the image below. That matters —
20
+ // `sdk/package.json` is in the base-snapshot discriminator, so a dependency
21
+ // here would force a base rebuild and one cold start for every project on
22
+ // the box, for components most projects never use.
23
+ //
24
+ // Each emulator serves every one of its endpoints on ONE origin. Real
25
+ // providers spread them over several hosts, and a few paths differ.
26
+ // `entry.mjs` routes by Host header, applies the per-provider path table,
27
+ // and corrects the discovery document. It also re-issues every id_token
28
+ // with one RSA key it generates at boot, and serves that key at the
29
+ // provider's real JWKS URL. That single step covers three separate faults:
30
+ // the Google emulator signs HS256 and publishes an empty JWKS (so nothing
31
+ // can verify its token), the Microsoft one cannot put the tenant in its
32
+ // issuer, and any future provider whose issuer is not its base URL is
33
+ // handled in advance.
34
+ //
35
+ // The Dockerfile is deliberately CONSTANT — every provider package is
36
+ // installed whether or not this component is the one using it. Two reasons:
37
+ // one image is shared by every provider component and every project on the
38
+ // box, so the layer cache is hit; and the daemon dedupes identical
39
+ // dockerfile builds inside one bootstrap, so five provider services in one
40
+ // environment cost one build, not five.
41
+ //
42
+ // `entry.mjs` ships as an asset next to this file, read at load time and
43
+ // injected with `files`. It is not a `.ts` string constant: it imports
44
+ // `@emulators/*`, which the SDK does not depend on, so `tsc` could not
45
+ // check it anyway, and 200 lines inside a template literal is worse to
46
+ // maintain. `.mjs` also keeps it out of `tsconfig`'s `include` with no
47
+ // exclude rule, and `files: ["src"]` in the manifest still ships it.
48
+
49
+ import { readFileSync } from "node:fs";
50
+ import path from "node:path";
51
+ import { fileURLToPath } from "node:url";
52
+
53
+ import type { ServiceDefinition } from "../../index.js";
54
+ import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
55
+
56
+ /** Base image, pinned. Bun runs the emulators' ESM directly. */
57
+ const IMAGE_BASE = "oven/bun:1.3.14-alpine";
58
+ /** Emulator package version, pinned rather than floated. */
59
+ const EMULATE_VERSION = "0.9.0";
60
+ /** The port the provider is served on. Callers reach it through the
61
+ * provider's real hostnames, never through this port. */
62
+ const PORT = 4000;
63
+ /** Ready-check budget. The container boots in about a second; the slow part
64
+ * is a cold image build on a machine that has never run it. */
65
+ const READY_TIMEOUT_SECS = 120;
66
+
67
+ /** A person who can sign in. Seeded into the provider's account picker. */
68
+ export interface ProviderUser {
69
+ /** Primary address. Also the account's test id on the picker page. */
70
+ email: string;
71
+ /** Display name. */
72
+ name?: string;
73
+ /** Avatar URL, passed through to the profile claims. */
74
+ picture?: string;
75
+ /** GitHub username. Defaults to the local part of `email`. */
76
+ login?: string;
77
+ }
78
+
79
+ /** An OAuth client, as registered with the real provider. Declaring one
80
+ * makes the environment **stricter**: `clientId`, `clientSecret` and
81
+ * `redirectUris` are then all validated, exactly as in production. Omit it
82
+ * and any client is accepted. */
83
+ export interface OAuthClient {
84
+ clientId: string;
85
+ clientSecret: string;
86
+ /** Callback URLs the app is allowed to return to. A request to any other
87
+ * one is refused, which is what makes a misconfigured callback fail here
88
+ * rather than in production. */
89
+ redirectUris?: string[];
90
+ }
91
+
92
+ /**
93
+ * The accounts every provider offers when a component declares none.
94
+ *
95
+ * Two, not one: a test that asserts it signed in as Alice only means
96
+ * something if the provider had somebody else to return. Both carry a
97
+ * `login`, so GitHub gets a handle without the caller supplying one.
98
+ *
99
+ * `example.com` is reserved for exactly this (RFC 2606), so no default
100
+ * account can ever collide with a real address.
101
+ */
102
+ export const DEFAULT_USERS: readonly ProviderUser[] = [
103
+ { email: "alice@example.com", name: "Alice Example", login: "alice" },
104
+ { email: "bob@example.com", name: "Bob Example", login: "bob" },
105
+ ];
106
+
107
+ /** Options every provider component takes. */
108
+ export interface ProviderOptions {
109
+ /**
110
+ * The accounts offered on the provider's picker page. Defaults to
111
+ * **Alice Example** (`alice@example.com`) and **Bob Example**
112
+ * (`bob@example.com`) — enough to sign in as somebody, and to tell two
113
+ * people apart, without declaring anything.
114
+ */
115
+ users?: ProviderUser[];
116
+ /** The app's OAuth client. Omit it and any client id, secret and callback
117
+ * URL are accepted. */
118
+ client?: OAuthClient;
119
+ }
120
+
121
+ /** What `entry.mjs` consumes. Everything provider-specific lives in the
122
+ * per-provider module; the script itself is generic. */
123
+ export interface ProviderSpec {
124
+ name: string;
125
+ module: string;
126
+ pluginExport: string;
127
+ /** Base URL the emulator advertises — the provider's real sign-in host. */
128
+ baseUrl: string;
129
+ /** Every hostname routed to this emulator. */
130
+ hosts: string[];
131
+ /** False for a plain OAuth 2.0 provider with no id_token (GitHub). */
132
+ oidc?: boolean;
133
+ /** Issuer to stamp on the re-issued id_token, where the emulator cannot
134
+ * derive it from `baseUrl`. */
135
+ issuer?: string;
136
+ /** Values merged over the emulator's discovery document. */
137
+ discovery?: Record<string, string>;
138
+ /** Path (as a regex source) answered with our own JWKS. */
139
+ jwksPath?: string;
140
+ /** Picker-page identifier → the account's email, where the provider
141
+ * identifies an account by something else. Keeps the account's test id
142
+ * the same on every provider. */
143
+ aliases?: Record<string, string>;
144
+ /** Real path → the path this emulator serves it on. Identity elsewhere. */
145
+ rewrites?: { from: string; to: string }[];
146
+ fallbackUser?: { login: string; id: number; scopes: string[] };
147
+ seed?: Record<string, unknown>;
148
+ }
149
+
150
+ /** The entry script, read from disk next to this module. */
151
+ const ENTRY_SCRIPT: string = (() => {
152
+ const here = path.dirname(fileURLToPath(import.meta.url));
153
+ return readFileSync(path.join(here, "entry.mjs"), "utf8");
154
+ })();
155
+
156
+ const DOCKERFILE = `
157
+ FROM ${IMAGE_BASE}
158
+ WORKDIR /srv
159
+ RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
160
+ && bun add \\
161
+ @emulators/core@${EMULATE_VERSION} \\
162
+ @emulators/google@${EMULATE_VERSION} \\
163
+ @emulators/github@${EMULATE_VERSION} \\
164
+ @emulators/apple@${EMULATE_VERSION} \\
165
+ @emulators/microsoft@${EMULATE_VERSION} \\
166
+ @emulators/okta@${EMULATE_VERSION} \\
167
+ jose@6
168
+ `;
169
+
170
+ /** The service definition for one provider: the container, plus the claim
171
+ * on its domains (one certificate carrying every hostname as a SAN, and a
172
+ * proxy route each). One `certificate(...)` rather than per-host `tls`
173
+ * entries, for the reason `aws()` gives — `tls` mints a separate leaf per
174
+ * entry, and a provider can claim four names. */
175
+ export function emulatorService(spec: ProviderSpec) {
176
+ const service = {
177
+ image: { type: "dockerfile" as const, content: DOCKERFILE },
178
+ command: "bun /srv/auth.mjs",
179
+ files: [{ path: "/srv/auth.mjs", content: ENTRY_SCRIPT }],
180
+ env: { SPECTEST_AUTH_CONFIG: JSON.stringify({ port: PORT, providers: [spec] }) },
181
+ ports: [PORT],
182
+ readyCheck: {
183
+ type: "http" as const,
184
+ port: PORT,
185
+ path: "/healthz",
186
+ timeoutSecs: READY_TIMEOUT_SECS,
187
+ },
188
+ } satisfies ServiceDefinition;
189
+
190
+ return provides(service, [
191
+ certificate(spec.hosts),
192
+ ...spec.hosts.map((hostname) => proxy(hostname, { service: SELF_SERVICE_TOKEN, port: PORT })),
193
+ ]);
194
+ }
195
+
196
+ // ── Seeding helpers, shared by the provider modules ───────────────────────
197
+
198
+ /** The accounts to seed: what the caller declared, or {@link DEFAULT_USERS}.
199
+ * An explicitly empty list is a mistake, not a request for none — a
200
+ * provider with no accounts can never sign anyone in. */
201
+ export function resolveUsers(component: string, users: ProviderUser[] | undefined): ProviderUser[] {
202
+ if (users && users.length === 0) {
203
+ throw new Error(`${component}(): \`users\` is empty — omit it for the default accounts`);
204
+ }
205
+ return users ?? [...DEFAULT_USERS];
206
+ }
207
+
208
+ export function localPart(email: string): string {
209
+ return email.split("@")[0] ?? email;
210
+ }
211
+
212
+ export function givenName(user: ProviderUser): string | undefined {
213
+ return user.name?.split(" ")[0];
214
+ }
215
+
216
+ export function familyName(user: ProviderUser): string | undefined {
217
+ const parts = user.name?.split(" ") ?? [];
218
+ return parts.length > 1 ? parts.slice(1).join(" ") : undefined;
219
+ }
220
+
221
+ /** The `oauth_clients` seed entry every provider but GitHub uses. */
222
+ export function oauthClients(client: OAuthClient | undefined, extra: Record<string, unknown> = {}) {
223
+ if (!client) return {};
224
+ return {
225
+ oauth_clients: [
226
+ {
227
+ client_id: client.clientId,
228
+ client_secret: client.clientSecret,
229
+ name: "spectest",
230
+ redirect_uris: client.redirectUris ?? [],
231
+ ...extra,
232
+ },
233
+ ],
234
+ };
235
+ }
@@ -0,0 +1,96 @@
1
+ // `github()` — GitHub, emulated inside the environment, answering at its
2
+ // real endpoints.
3
+ //
4
+ // Today that is Sign in with GitHub — the authorize page, the token
5
+ // exchange, and the profile reads every GitHub sign-in makes (`/user`,
6
+ // `/user/emails`). The component is named for the provider rather than for
7
+ // the feature: the backing emulator carries most of the REST API — repos,
8
+ // issues, pull requests, checks, webhooks — and reaching it is a matter of
9
+ // what this component chooses to expose, not of new plumbing.
10
+ //
11
+ // Landmine for whoever extends this: the claim is env-wide. `github.com`
12
+ // and `api.github.com` stop reaching the internet for every container in
13
+ // the environment, so anything else that talks to GitHub — a build step
14
+ // that clones a repository, an unrelated API call — reaches the emulator
15
+ // and gets a 404 outside the surface it serves.
16
+
17
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
18
+ import { emulatorService, localPart, resolveUsers } from "./emulate/service.js";
19
+
20
+ export type GithubOptions = ProviderOptions;
21
+
22
+ function spec(users: ProviderUser[], opts: GithubOptions): ProviderSpec {
23
+ const client = opts.client;
24
+ return {
25
+ name: "github",
26
+ module: "@emulators/github",
27
+ pluginExport: "githubPlugin",
28
+ baseUrl: "https://github.com",
29
+ // api.github.com cannot be avoided: every GitHub sign-in reads the
30
+ // profile from /user and the addresses from /user/emails.
31
+ hosts: ["github.com", "api.github.com"],
32
+ // OAuth 2.0, not OIDC — there is no id_token to re-issue and no JWKS.
33
+ oidc: false,
34
+ // GitHub identifies an account by login, not by address.
35
+ aliases: Object.fromEntries(users.map((u) => [u.login ?? localPart(u.email), u.email])),
36
+ fallbackUser: {
37
+ login: users[0]!.login ?? localPart(users[0]!.email),
38
+ id: 1,
39
+ scopes: ["user", "user:email"],
40
+ },
41
+ seed: {
42
+ users: users.map((u) => ({
43
+ login: u.login ?? localPart(u.email),
44
+ name: u.name,
45
+ email: u.email,
46
+ })),
47
+ // GitHub calls them oauth_apps, and its own field is `oauth_apps`.
48
+ ...(client
49
+ ? {
50
+ oauth_apps: [
51
+ {
52
+ client_id: client.clientId,
53
+ client_secret: client.clientSecret,
54
+ name: "spectest",
55
+ redirect_uris: client.redirectUris ?? [],
56
+ },
57
+ ],
58
+ }
59
+ : {}),
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * GitHub, answering at its real endpoints. Drop into
66
+ * `environment.services`:
67
+ *
68
+ * ```ts
69
+ * import { github } from "@specific.dev/spectest/components";
70
+ *
71
+ * services: {
72
+ * github: github({
73
+ * users: [{ email: "bob@example.com", name: "Bob Example", login: "bob" }],
74
+ * client: {
75
+ * clientId: "Iv1.example",
76
+ * clientSecret: "ghs_example",
77
+ * redirectUris: ["https://app.test/callback/github"],
78
+ * },
79
+ * }),
80
+ * app: { …, dependsOn: ["github"] },
81
+ * }
82
+ * ```
83
+ *
84
+ * The app keeps its production configuration — it sends a browser to
85
+ * `https://github.com/login/oauth/authorize`, exchanges the code at
86
+ * `https://github.com/login/oauth/access_token`, and reads the account from
87
+ * `https://api.github.com/user`.
88
+ *
89
+ * The account's test id on the picker page is its **email**, even though
90
+ * GitHub identifies it by login — so a test reads the same whichever
91
+ * provider it drives.
92
+ */
93
+ export function github(opts: GithubOptions = {}) {
94
+ const users = resolveUsers("github", opts.users);
95
+ return emulatorService(spec(users, opts));
96
+ }
@@ -0,0 +1,104 @@
1
+ // `google()` — Google, emulated inside the environment, answering at its
2
+ // real endpoints.
3
+ //
4
+ // Today that is Sign in with Google: OpenID Connect discovery, the account
5
+ // picker, PKCE, the token exchange, refresh, and userinfo. The component is
6
+ // named for the provider rather than for the feature, because the backing
7
+ // emulator also carries Gmail, Calendar and Drive — claiming
8
+ // `gmail.googleapis.com` and routing it here is a table entry, not a new
9
+ // design.
10
+
11
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
12
+ import {
13
+ emulatorService,
14
+ familyName,
15
+ givenName,
16
+ oauthClients,
17
+ resolveUsers,
18
+ } from "./emulate/service.js";
19
+
20
+ export type GoogleOptions = ProviderOptions;
21
+
22
+ function spec(users: ProviderUser[], opts: GoogleOptions): ProviderSpec {
23
+ return {
24
+ name: "google",
25
+ module: "@emulators/google",
26
+ pluginExport: "googlePlugin",
27
+ baseUrl: "https://accounts.google.com",
28
+ hosts: [
29
+ "accounts.google.com",
30
+ "oauth2.googleapis.com",
31
+ "www.googleapis.com",
32
+ "openidconnect.googleapis.com",
33
+ ],
34
+ discovery: {
35
+ issuer: "https://accounts.google.com",
36
+ authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
37
+ token_endpoint: "https://oauth2.googleapis.com/token",
38
+ userinfo_endpoint: "https://openidconnect.googleapis.com/v1/userinfo",
39
+ revocation_endpoint: "https://oauth2.googleapis.com/revoke",
40
+ jwks_uri: "https://www.googleapis.com/oauth2/v3/certs",
41
+ },
42
+ jwksPath: "^/oauth2/v3/certs$",
43
+ rewrites: [
44
+ // oauth2.googleapis.com serves these at the root; the emulator puts
45
+ // them under /oauth2.
46
+ { from: "^/token$", to: "/oauth2/token" },
47
+ { from: "^/revoke$", to: "/oauth2/revoke" },
48
+ // openidconnect.googleapis.com and the v3 alias both mean userinfo.
49
+ { from: "^/v1/userinfo$", to: "/oauth2/v2/userinfo" },
50
+ { from: "^/oauth2/v3/userinfo$", to: "/oauth2/v2/userinfo" },
51
+ ],
52
+ fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "email", "profile"] },
53
+ seed: {
54
+ users: users.map((u) => ({
55
+ email: u.email,
56
+ name: u.name,
57
+ given_name: givenName(u),
58
+ family_name: familyName(u),
59
+ picture: u.picture,
60
+ email_verified: true,
61
+ })),
62
+ ...oauthClients(opts.client),
63
+ },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Google, answering at its real endpoints. Drop into
69
+ * `environment.services`:
70
+ *
71
+ * ```ts
72
+ * import { google } from "@specific.dev/spectest/components";
73
+ *
74
+ * services: {
75
+ * // Accounts default to Alice and Bob Example; declare `users` to
76
+ * // choose your own. A `client` makes the environment validate the
77
+ * // app's client id, secret and callback URL.
78
+ * google: google({
79
+ * client: {
80
+ * clientId: "1234.apps.googleusercontent.com",
81
+ * clientSecret: "GOCSPX-test",
82
+ * redirectUris: ["https://app.test/callback/google"],
83
+ * },
84
+ * }),
85
+ * app: { …, dependsOn: ["google"] },
86
+ * }
87
+ * ```
88
+ *
89
+ * The app keeps its production configuration — it sends a browser to
90
+ * `https://accounts.google.com/o/oauth2/v2/auth`, exchanges the code at
91
+ * `https://oauth2.googleapis.com/token`, and verifies the id_token against
92
+ * `https://www.googleapis.com/oauth2/v3/certs`, exactly as it does live.
93
+ *
94
+ * A test signs in the way a user does, through the browser:
95
+ *
96
+ * ```ts
97
+ * await page.getByTestId("login-google").click();
98
+ * await page.getByTestId("spectest-user-alice@example.com").click();
99
+ * ```
100
+ */
101
+ export function google(opts: GoogleOptions = {}) {
102
+ const users = resolveUsers("google", opts.users);
103
+ return emulatorService(spec(users, opts));
104
+ }
@@ -62,6 +62,20 @@ export {
62
62
  type AwsOptions,
63
63
  type LambdaOptions,
64
64
  } from "./aws.js";
65
+ // Third-party providers, each answering at its own real endpoints. Named
66
+ // for the provider rather than for sign-in, because each one's surface can
67
+ // grow past auth (Gmail and Calendar for `google`, the REST API for
68
+ // `github`, Graph for `microsoft`).
69
+ export {
70
+ type ProviderOptions,
71
+ type ProviderUser,
72
+ type OAuthClient,
73
+ } from "./emulate/service.js";
74
+ export { google, type GoogleOptions } from "./google.js";
75
+ export { github, type GithubOptions } from "./github.js";
76
+ export { apple, type AppleOptions } from "./apple.js";
77
+ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
78
+ export { okta, type OktaOptions } from "./okta.js";
65
79
  export {
66
80
  replayFake,
67
81
  type ReplayFakeOptions,
@@ -0,0 +1,89 @@
1
+ // `microsoft()` — Microsoft, emulated inside the environment, answering at
2
+ // its real endpoints.
3
+ //
4
+ // Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
5
+ // app reads straight after: discovery, the account picker, the token
6
+ // exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
7
+ // provider rather than for the feature — Graph is already claimed, and
8
+ // widening what it serves is a table entry.
9
+
10
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
11
+ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
12
+
13
+ export interface MicrosoftOptions extends ProviderOptions {
14
+ /**
15
+ * Directory (tenant) the app signs in against — whatever its authority
16
+ * URL uses. It appears in the issuer and in every endpoint path, so a
17
+ * client that checks the issuer sees what it expects. Default `"common"`.
18
+ */
19
+ tenantId?: string;
20
+ }
21
+
22
+ function spec(users: ProviderUser[], opts: MicrosoftOptions): ProviderSpec {
23
+ const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
24
+ return {
25
+ name: "microsoft",
26
+ module: "@emulators/microsoft",
27
+ pluginExport: "microsoftPlugin",
28
+ baseUrl: "https://login.microsoftonline.com",
29
+ hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
30
+ // The emulator has no tenant, so its issuer is the bare host. A client
31
+ // that checks the issuer against the authority it configured would
32
+ // reject that; the re-issue step corrects it.
33
+ issuer: `${authority}/v2.0`,
34
+ discovery: {
35
+ issuer: `${authority}/v2.0`,
36
+ authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
37
+ token_endpoint: `${authority}/oauth2/v2.0/token`,
38
+ jwks_uri: `${authority}/discovery/v2.0/keys`,
39
+ end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
40
+ userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
41
+ },
42
+ jwksPath: "^/discovery/v2\\.0/keys$",
43
+ rewrites: [
44
+ // Real Entra paths carry the tenant; the emulator's do not. The
45
+ // discovery route is the exception — it handles the tenant itself.
46
+ { from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
47
+ { from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
48
+ ],
49
+ fallbackUser: {
50
+ login: users[0]!.email,
51
+ id: 1,
52
+ scopes: ["openid", "email", "profile", "User.Read"],
53
+ },
54
+ seed: {
55
+ users: users.map((u) => ({ email: u.email, name: u.name })),
56
+ ...oauthClients(opts.client),
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Microsoft, answering at its real endpoints. Drop into
63
+ * `environment.services`:
64
+ *
65
+ * ```ts
66
+ * import { microsoft } from "@specific.dev/spectest/components";
67
+ *
68
+ * services: {
69
+ * microsoft: microsoft({
70
+ * tenantId: "11111111-2222-3333-4444-555555555555",
71
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
72
+ * client: {
73
+ * clientId: "6f1a2b3c-…",
74
+ * clientSecret: "secret",
75
+ * redirectUris: ["https://app.test/callback/microsoft"],
76
+ * },
77
+ * }),
78
+ * app: { …, dependsOn: ["microsoft"] },
79
+ * }
80
+ * ```
81
+ *
82
+ * The app keeps its production configuration — discovery at
83
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
84
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
85
+ */
86
+ export function microsoft(opts: MicrosoftOptions = {}) {
87
+ const users = resolveUsers("microsoft", opts.users);
88
+ return emulatorService(spec(users, opts));
89
+ }
@@ -0,0 +1,93 @@
1
+ // `okta()` — Okta, emulated inside the environment, answering at your org's
2
+ // real domain.
3
+ //
4
+ // Today that is Okta sign-in: discovery, the account picker, the token
5
+ // exchange, userinfo, introspection and revocation, on both the org
6
+ // authorization server (`/oauth2/v1/…`) and a custom one
7
+ // (`/oauth2/default/…`). The component is named for the provider rather
8
+ // than for the feature — the backing emulator also carries the users,
9
+ // groups and apps management API.
10
+ //
11
+ // Okta has no fixed hostname: every organization gets its own domain, so
12
+ // `domain` is required where the other providers need nothing.
13
+
14
+ import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
15
+ import {
16
+ emulatorService,
17
+ familyName,
18
+ givenName,
19
+ oauthClients,
20
+ resolveUsers,
21
+ } from "./emulate/service.js";
22
+
23
+ export interface OktaOptions extends ProviderOptions {
24
+ /** The org's domain, e.g. `"dev-12345.okta.com"`. */
25
+ domain: string;
26
+ }
27
+
28
+ /** Okta's picker identifies an account by its opaque Okta id, so the id has
29
+ * to be seeded rather than generated — otherwise nothing on the page can be
30
+ * mapped back to an address. */
31
+ function oktaId(email: string): string {
32
+ return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
33
+ }
34
+
35
+ function spec(users: ProviderUser[], opts: OktaOptions): ProviderSpec {
36
+ const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
37
+ return {
38
+ name: "okta",
39
+ module: "@emulators/okta",
40
+ pluginExport: "oktaPlugin",
41
+ baseUrl: `https://${domain}`,
42
+ hosts: [domain],
43
+ // Okta's issuer is per authorization server and the emulator already
44
+ // builds it from the base URL, which here is the real org domain.
45
+ jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
46
+ aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
47
+ fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "profile", "email"] },
48
+ seed: {
49
+ users: users.map((u) => ({
50
+ okta_id: oktaId(u.email),
51
+ // An Okta login is the address, not the GitHub-style handle that
52
+ // `login` carries — it comes back as `preferred_username`.
53
+ login: u.email,
54
+ email: u.email,
55
+ first_name: givenName(u),
56
+ last_name: familyName(u),
57
+ display_name: u.name,
58
+ })),
59
+ ...oauthClients(opts.client),
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Okta, answering at your org's real domain. Drop into
66
+ * `environment.services`:
67
+ *
68
+ * ```ts
69
+ * import { okta } from "@specific.dev/spectest/components";
70
+ *
71
+ * services: {
72
+ * okta: okta({
73
+ * domain: "dev-12345.okta.com",
74
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
75
+ * client: {
76
+ * clientId: "0oaexample",
77
+ * clientSecret: "secret",
78
+ * redirectUris: ["https://app.test/callback/okta"],
79
+ * },
80
+ * }),
81
+ * app: { …, dependsOn: ["okta"] },
82
+ * }
83
+ * ```
84
+ *
85
+ * The app keeps its production configuration — discovery at
86
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
87
+ * everything it names.
88
+ */
89
+ export function okta(opts: OktaOptions) {
90
+ const users = resolveUsers("okta", opts.users);
91
+ if (!opts.domain) throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
92
+ return emulatorService(spec(users, opts));
93
+ }