@dehwyyy/auth 1.0.5 → 2.0.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.
Files changed (69) hide show
  1. package/README.md +185 -50
  2. package/dist/v2/auth/client/baseClient.d.ts +2 -0
  3. package/dist/v2/auth/client/baseClient.js +14 -0
  4. package/dist/v2/auth/client/client.d.ts +3 -0
  5. package/dist/v2/auth/client/client.js +7 -0
  6. package/dist/v2/auth/client/index.d.ts +3 -0
  7. package/dist/v2/auth/client/index.js +3 -0
  8. package/dist/v2/auth/factory.d.ts +20 -0
  9. package/dist/v2/auth/factory.js +31 -0
  10. package/dist/v2/auth/service/baseService.d.ts +10 -0
  11. package/dist/v2/auth/service/baseService.js +82 -0
  12. package/dist/v2/auth/service/index.d.ts +3 -0
  13. package/dist/v2/auth/service/index.js +3 -0
  14. package/dist/v2/auth/service/service.d.ts +19 -0
  15. package/dist/v2/auth/service/service.js +162 -0
  16. package/dist/v2/client/builder/builder.d.ts +7 -0
  17. package/dist/v2/client/builder/builder.js +14 -0
  18. package/dist/v2/client/builder/index.d.ts +2 -0
  19. package/dist/v2/client/builder/index.js +2 -0
  20. package/dist/v2/client/client.d.ts +7 -0
  21. package/dist/v2/client/client.js +9 -0
  22. package/dist/v2/client/index.d.ts +4 -0
  23. package/dist/v2/client/index.js +4 -0
  24. package/dist/v2/client/middleware/builder.d.ts +16 -0
  25. package/dist/v2/client/middleware/builder.js +48 -0
  26. package/dist/v2/client/middleware/index.d.ts +2 -0
  27. package/dist/v2/client/middleware/index.js +2 -0
  28. package/dist/v2/client/middleware/middleware.d.ts +7 -0
  29. package/dist/v2/client/middleware/middleware.js +39 -0
  30. package/dist/v2/index.d.ts +11 -0
  31. package/dist/v2/index.js +7 -0
  32. package/dist/v2/pkg/errors/errors.d.ts +5 -0
  33. package/dist/v2/pkg/errors/errors.js +9 -0
  34. package/dist/v2/pkg/errors/index.d.ts +4 -0
  35. package/dist/v2/pkg/errors/index.js +2 -0
  36. package/dist/v2/pkg/localStorage/index.d.ts +2 -0
  37. package/dist/v2/pkg/localStorage/index.js +2 -0
  38. package/dist/v2/pkg/localStorage/localStorage.d.ts +20 -0
  39. package/dist/v2/pkg/localStorage/localStorage.js +34 -0
  40. package/dist/v2/pkg/pkce/index.d.ts +2 -0
  41. package/dist/v2/pkg/pkce/index.js +2 -0
  42. package/dist/v2/pkg/pkce/pkce.d.ts +3 -0
  43. package/dist/v2/pkg/pkce/pkce.js +30 -0
  44. package/dist/v2/pkg/types/authClient.d.ts +3 -0
  45. package/dist/{types.js → v2/pkg/types/authClient.js} +0 -1
  46. package/dist/v2/pkg/types/authService.d.ts +42 -0
  47. package/dist/v2/pkg/types/authService.js +1 -0
  48. package/dist/v2/pkg/types/index.d.ts +3 -0
  49. package/dist/v2/pkg/types/index.js +1 -0
  50. package/dist/v2/pkg/url/index.d.ts +2 -0
  51. package/dist/v2/pkg/url/index.js +2 -0
  52. package/dist/v2/pkg/url/url.d.ts +8 -0
  53. package/dist/v2/pkg/url/url.js +40 -0
  54. package/dist/v2/vue/router-guard/guard.d.ts +27 -0
  55. package/dist/v2/vue/router-guard/guard.js +105 -0
  56. package/dist/v2/vue/router-guard/index.d.ts +1 -0
  57. package/dist/v2/vue/router-guard/index.js +1 -0
  58. package/package.json +16 -9
  59. package/dist/client/client.d.ts +0 -2
  60. package/dist/client/client.js +0 -14
  61. package/dist/client/middleware.d.ts +0 -15
  62. package/dist/client/middleware.js +0 -51
  63. package/dist/client/storage/localStorage.d.ts +0 -8
  64. package/dist/client/storage/localStorage.js +0 -15
  65. package/dist/guard/index.d.ts +0 -11
  66. package/dist/guard/index.js +0 -62
  67. package/dist/index.d.ts +0 -26
  68. package/dist/index.js +0 -160
  69. package/dist/types.d.ts +0 -16
package/README.md CHANGED
@@ -1,50 +1,185 @@
1
- ## TokenAttacher & TokenRefresher middleware usage
2
-
3
- ```ts
4
- import { GetAuthService } from "@dehwyyy/auth";
5
- import type { Middleware } from "openapi-fetch";
6
-
7
- export const AuthorizationHeaderAttacherMiddleware: Middleware = {
8
- async onRequest({ request }) {
9
- return GetAuthService(APP, BASE_URL).WithAuthorizationToken(request);
10
- },
11
- }
12
-
13
- export const TokenRefresherMiddleware: Middleware = {
14
- async onResponse({ response, request }) {
15
- if (response.status !== 401 || request.url.includes('/auth/refresh')) {
16
- return response;
17
- }
18
-
19
- return GetAuthService(APP, BASE_URL).RefreshAndRetry(request, response);
20
- }
21
- }
22
-
23
-
24
- ```
25
-
26
- ## Guard usage example
27
-
28
- ```ts
29
- import { Guards } from '@dehwyyy/auth/guard';
30
- import { createRouter, createWebHistory } from 'vue-router';
31
-
32
- enum Roles {
33
- ADMIN = "Admin",
34
- MANAGER = "Manager",
35
- USER = "User"
36
- }
37
-
38
- const g = new Guards(APP, BASE_URL, "/login")
39
-
40
- export const router = createRouter({
41
- history: createWebHistory(import.meta.env.VITE_BASE_URL),
42
- routes: [
43
- { path: "/login", name: "Login", component: Login },
44
- { path: "/dashboard", name: "DashboardIndex", component: View, beforeEnter: g.Auth([Roles.USER]) },
45
- { path: "/dashboard/users", name: "DashboardUsers", component: Users, beforeEnter: g.Auth([Roles.ADMIN, Roles.MANAGER]) },
46
- { path: "/:pathMatch(.*)*", redirect: "/login" },
47
- ],
48
- });
49
-
50
- ```
1
+ # @dehwyyy/auth
2
+
3
+ Browser-side OIDC auth client for the Paylonium SPAs. Talks directly to Zitadel
4
+ using Authorization Code + PKCE (S256). No backend auth proxy.
5
+
6
+ - ESM only, single runtime dependency: `openapi-fetch`.
7
+ - PKCE via WebCrypto (no extra deps).
8
+ - Tokens in `localStorage` (`accessToken` / `refreshToken` / `idToken`).
9
+ - PKCE `verifier` / `state` / `returnTo` in `sessionStorage`.
10
+ - Refresh-token rotation with single-flight and cross-tab compare-and-clear.
11
+ - Vue Router guard with role checks and an OIDC-callback anti-loop.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ bun add @dehwyyy/auth
17
+ ```
18
+
19
+ Both `@dehwyyy/auth` and `@dehwyyy/auth/v2` resolve to the same v2 entrypoint.
20
+
21
+ ## Configuration
22
+
23
+ ```ts
24
+ import type { ZitadelConfig } from "@dehwyyy/auth"
25
+
26
+ const config: ZitadelConfig = {
27
+ issuer: "https://core.auth.my-paylonium.com",
28
+ clientId: "376852297126373720",
29
+ redirectUri: window.location.origin + "/auth/callback",
30
+ postLogoutRedirectUri: window.location.origin + "/",
31
+ // scopes is optional; this is the default:
32
+ // ["openid","profile","email","offline_access",
33
+ // "urn:zitadel:iam:org:projects:roles","urn:zitadel:iam:user:metadata"]
34
+ }
35
+ ```
36
+
37
+ ## Wiring with `createAuth`
38
+
39
+ `createAuth` is the single wiring entry point. It builds the refresh client, the
40
+ authenticated API client (access-token attach + auto-refresh on 401), the
41
+ services, and a `MiddlewareBuilder` you can reuse for your own API clients.
42
+
43
+ ```ts
44
+ import { createAuth } from "@dehwyyy/auth"
45
+
46
+ export const auth = createAuth(config)
47
+ // auth.authService — Login / HandleCallback / GetMe / Logout
48
+ // auth.baseAuthService — Refresh / RequestWithAccessToken
49
+ // auth.middlewareBuilder — for wiring your own openapi-fetch clients
50
+ // auth.callbackPath — derived from config.redirectUri (e.g. "/auth/callback")
51
+ ```
52
+
53
+ Reuse the middleware for your domain API client:
54
+
55
+ ```ts
56
+ import createClient from "openapi-fetch"
57
+ import type { paths } from "./api/schema"
58
+
59
+ export const api = createClient<paths>({ baseUrl: import.meta.env.VITE_API_URL })
60
+ api.use(...auth.middlewareBuilder.buildDefault())
61
+ ```
62
+
63
+ ## Login
64
+
65
+ ```ts
66
+ // returnTo is optional; pass the path you want to land on after auth.
67
+ await auth.authService.Login(router.currentRoute.value.fullPath)
68
+ // redirects the browser to Zitadel's /oauth/v2/authorize
69
+ ```
70
+
71
+ ## Callback page
72
+
73
+ Mount this on `config.redirectUri` (e.g. `/auth/callback`). It performs the
74
+ token exchange, fetches the user, and redirects to `returnTo`.
75
+
76
+ ```vue
77
+ <script setup lang="ts">
78
+ import { onMounted } from "vue"
79
+ import { useRouter } from "vue-router"
80
+ import { CallbackError } from "@dehwyyy/auth"
81
+ import { auth } from "@/auth"
82
+
83
+ const router = useRouter()
84
+
85
+ onMounted(async () => {
86
+ try {
87
+ const { returnTo } = await auth.authService.HandleCallback()
88
+ // returnTo is already same-origin/relative-validated by the library
89
+ // (a foreign origin is dropped to null), so it is safe to navigate to.
90
+ await router.replace(returnTo ?? "/")
91
+ } catch (e) {
92
+ if (e instanceof CallbackError) {
93
+ // e.code: "state_mismatch" | "verifier_missing" | "code_missing" | "exchange_failed"
94
+ console.error("auth callback failed:", e.code)
95
+ }
96
+ await router.replace("/")
97
+ }
98
+ })
99
+ </script>
100
+
101
+ <template>
102
+ <div>Signing you in…</div>
103
+ </template>
104
+ ```
105
+
106
+ `HandleCallback` is idempotent per authorization code: a double mount (Vue dev
107
+ StrictMode) or an F5 on the callback URL reuses the in-flight/resolved exchange
108
+ instead of failing with a state mismatch.
109
+
110
+ ## Route guard
111
+
112
+ ```ts
113
+ import { RouterAuthGuard } from "@dehwyyy/auth"
114
+ import { auth } from "@/auth"
115
+
116
+ const guard = new RouterAuthGuard(auth.authService, {
117
+ callbackPath: auth.callbackPath, // required — only this route may carry ?code&state
118
+ fallbackPage: "/forbidden",
119
+ baseServiceURL: window.location.origin,
120
+ onGetMe: (user) => { /* hydrate your store */ },
121
+ // cacheTTL is optional, defaults to 15000 (ms)
122
+ })
123
+
124
+ router.beforeEach(guard.Authorized(["ADMIN", "DEV"])) // require one of these roles
125
+ // or guard.Authorized() to require only authentication
126
+ // or guard.Unauthorized([{ to: "/dashboard", roles: ["MERCHANT"] }]) on public pages
127
+ ```
128
+
129
+ Security note: the guard only treats a navigation as the OIDC callback when the
130
+ target path equals `callbackPath` **and** `code`+`state` are present. Appending
131
+ `?code&state` to any other protected route does not bypass the guard.
132
+
133
+ ## Logout
134
+
135
+ ```ts
136
+ await auth.authService.Logout()
137
+ // clears tokens and redirects to Zitadel /oidc/v1/end_session
138
+ ```
139
+
140
+ ## Composability (without `createAuth`)
141
+
142
+ The pieces remain individually exported for custom wiring:
143
+ `AuthService`, `BaseAuthService`, `ClientBuilder`, `ClientInstance`,
144
+ `MiddlewareBuilder`, `CreateAuthClientInstance`, `BaseAuthClientInstance`,
145
+ `RouterAuthGuard`, `CallbackError`, plus types
146
+ `ZitadelConfig`, `GetMeResponse`, `VerboseUserInfo`, `HandleCallbackResult`,
147
+ `RouteLocation`, `CallbackErrorCode`, `Auth`.
148
+
149
+ ### Footgun: `BaseAuthClientInstance` is a process-wide singleton
150
+
151
+ `BaseAuthClientInstance(issuer)` caches its client on first call — the first
152
+ `issuer` wins for the module's lifetime, and later calls with a different issuer
153
+ return the original client. For a single-issuer SPA this is fine. If you need
154
+ multiple issuers (or isolated instances in tests), use `createAuth` instead,
155
+ which constructs fresh clients each call.
156
+
157
+ ## GetMe response shape
158
+
159
+ ```ts
160
+ type GetMeResponse = {
161
+ user_id: string
162
+ roles: string[] // keys of the Zitadel project-roles claim
163
+ active: boolean // always true when a userinfo response is returned
164
+ info?: { // populated only when verbose
165
+ username?: string
166
+ email?: string
167
+ email_verified?: boolean
168
+ name?: string
169
+ picture?: string
170
+ }
171
+ }
172
+ ```
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ bun install
178
+ bun run gen # regenerate src/v2/pkg/types/schema.d.ts from spec/zitadel-oidc.yaml
179
+ bun run build # tsc + tsc-alias (full-path ESM resolution)
180
+ bun test
181
+ ```
182
+
183
+ The OIDC surface (`/oauth/v2/token`, `/oidc/v1/userinfo`, `/oidc/v1/end_session`)
184
+ is described in `spec/zitadel-oidc.yaml`; all schema types are generated from it
185
+ via `openapi-typescript`. Do not hand-edit `schema.d.ts`.
@@ -0,0 +1,2 @@
1
+ import { HttpAuthClient } from '../../../v2/pkg/types/index.js';
2
+ export declare const BaseAuthClientInstance: (issuer: string) => HttpAuthClient;
@@ -0,0 +1,14 @@
1
+ import { normalizeIssuer } from '../../../v2/pkg/url/index.js';
2
+ import createClient from 'openapi-fetch';
3
+ let baseClient = null;
4
+ // NOTE: process-wide singleton — the first issuer wins for the lifetime of the
5
+ // module. Re-calling with a different issuer returns the original client. Fine
6
+ // for the single-issuer SPA setups this package targets; see README footgun.
7
+ export const BaseAuthClientInstance = (issuer) => {
8
+ if (!baseClient) {
9
+ baseClient = createClient({
10
+ baseUrl: normalizeIssuer(issuer),
11
+ });
12
+ }
13
+ return baseClient;
14
+ };
@@ -0,0 +1,3 @@
1
+ import { BaseAuthService } from "../../../v2/pkg/types/index.js";
2
+ import { paths } from "@dehwyyy/auth/v2/pkg/types/schema";
3
+ export declare const CreateAuthClientInstance: (issuer: string, baseAuthService: BaseAuthService, fetchImpl?: typeof fetch) => import("openapi-fetch").Client<paths, `${string}/${string}`>;
@@ -0,0 +1,7 @@
1
+ import { ClientBuilder, MiddlewareBuilder } from "../../../v2/client/index.js";
2
+ import { normalizeIssuer } from "../../../v2/pkg/url/index.js";
3
+ export const CreateAuthClientInstance = (issuer, baseAuthService, fetchImpl = fetch) => new ClientBuilder().
4
+ withMiddleware(...new MiddlewareBuilder(baseAuthService, fetchImpl).buildDefault()).
5
+ build({
6
+ baseUrl: normalizeIssuer(issuer),
7
+ });
@@ -0,0 +1,3 @@
1
+ import { BaseAuthClientInstance } from "../../../v2/auth/client/baseClient.js";
2
+ import { CreateAuthClientInstance } from "../../../v2/auth/client/client.js";
3
+ export { BaseAuthClientInstance, CreateAuthClientInstance };
@@ -0,0 +1,3 @@
1
+ import { BaseAuthClientInstance } from "../../../v2/auth/client/baseClient.js";
2
+ import { CreateAuthClientInstance } from "../../../v2/auth/client/client.js";
3
+ export { BaseAuthClientInstance, CreateAuthClientInstance };
@@ -0,0 +1,20 @@
1
+ import { BaseAuthService } from "../../v2/auth/service/baseService.js";
2
+ import { AuthService } from "../../v2/auth/service/service.js";
3
+ import { MiddlewareBuilder } from "../../v2/client/index.js";
4
+ import { ZitadelConfig } from "../../v2/pkg/types/index.js";
5
+ export type Auth = {
6
+ authService: AuthService;
7
+ baseAuthService: BaseAuthService;
8
+ middlewareBuilder: MiddlewareBuilder;
9
+ callbackPath: string;
10
+ };
11
+ /**
12
+ * Single wiring entry point for the three SPA frontends. Builds:
13
+ * - a bare token client for refresh (no auto-refresh middleware to avoid
14
+ * a refresh -> 401 -> refresh loop),
15
+ * - the BaseAuthService driving that client,
16
+ * - an authenticated client (access token attach + auto-refresh on 401),
17
+ * - the AuthService over it,
18
+ * - a MiddlewareBuilder for callers wiring their own API clients.
19
+ */
20
+ export declare function createAuth(config: ZitadelConfig, fetchImpl?: typeof fetch): Auth;
@@ -0,0 +1,31 @@
1
+ import { BaseAuthService } from "../../v2/auth/service/baseService.js";
2
+ import { AuthService } from "../../v2/auth/service/service.js";
3
+ import { ClientBuilder, MiddlewareBuilder } from "../../v2/client/index.js";
4
+ import { normalizeIssuer, pathnameOf } from "../../v2/pkg/url/index.js";
5
+ import createClient from "openapi-fetch";
6
+ /**
7
+ * Single wiring entry point for the three SPA frontends. Builds:
8
+ * - a bare token client for refresh (no auto-refresh middleware to avoid
9
+ * a refresh -> 401 -> refresh loop),
10
+ * - the BaseAuthService driving that client,
11
+ * - an authenticated client (access token attach + auto-refresh on 401),
12
+ * - the AuthService over it,
13
+ * - a MiddlewareBuilder for callers wiring their own API clients.
14
+ */
15
+ export function createAuth(config, fetchImpl = fetch) {
16
+ const issuer = normalizeIssuer(config.issuer);
17
+ const normalizedConfig = { ...config, issuer };
18
+ const tokenClient = createClient({ baseUrl: issuer });
19
+ const baseAuthService = new BaseAuthService(tokenClient, normalizedConfig);
20
+ const middlewareBuilder = new MiddlewareBuilder(baseAuthService, fetchImpl);
21
+ const authClient = new ClientBuilder()
22
+ .withMiddleware(...middlewareBuilder.buildDefault())
23
+ .build({ baseUrl: issuer });
24
+ const authService = new AuthService(baseAuthService, authClient, normalizedConfig);
25
+ return {
26
+ authService,
27
+ baseAuthService,
28
+ middlewareBuilder,
29
+ callbackPath: pathnameOf(config.redirectUri),
30
+ };
31
+ }
@@ -0,0 +1,10 @@
1
+ import { HttpAuthClient, BaseAuthService as IBaseAuthService, ZitadelConfig } from "../../../v2/pkg/types/index.js";
2
+ export declare class BaseAuthService implements IBaseAuthService {
3
+ private httpAuthClient;
4
+ private config;
5
+ private refreshPromise;
6
+ constructor(httpAuthClient: HttpAuthClient, config: ZitadelConfig);
7
+ private doRefresh;
8
+ Refresh(): Promise<string | null>;
9
+ RequestWithAccessToken(req: Request, token?: string | null): Request;
10
+ }
@@ -0,0 +1,82 @@
1
+ import { Storage, StorageKey } from "../../../v2/pkg/localStorage/index.js";
2
+ export class BaseAuthService {
3
+ httpAuthClient;
4
+ config;
5
+ refreshPromise = null;
6
+ constructor(httpAuthClient, config) {
7
+ this.httpAuthClient = httpAuthClient;
8
+ this.config = config;
9
+ }
10
+ async doRefresh() {
11
+ const refreshToken = Storage.Get(StorageKey.REFRESH_TOKEN);
12
+ if (!refreshToken) {
13
+ return null;
14
+ }
15
+ const body = new URLSearchParams({
16
+ grant_type: 'refresh_token',
17
+ refresh_token: refreshToken,
18
+ client_id: this.config.clientId,
19
+ });
20
+ const scopes = this.config.scopes;
21
+ if (scopes && scopes.length > 0) {
22
+ body.set('scope', scopes.join(' '));
23
+ }
24
+ const { data, error } = await this.httpAuthClient.POST('/oauth/v2/token', {
25
+ body: body,
26
+ bodySerializer: () => body.toString(),
27
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
28
+ });
29
+ if (error || !data?.access_token) {
30
+ // Compare-and-clear: another tab may have already rotated the token while
31
+ // this request was in flight. Only drop stored credentials when they are
32
+ // still the ones we attempted with, and only for a hard auth failure.
33
+ const stored = Storage.Get(StorageKey.REFRESH_TOKEN);
34
+ const isInvalidGrant = error?.error === 'invalid_grant';
35
+ if (isInvalidGrant && stored === refreshToken) {
36
+ Storage.Delete(StorageKey.ACCESS_TOKEN);
37
+ Storage.Delete(StorageKey.REFRESH_TOKEN);
38
+ Storage.Delete(StorageKey.ID_TOKEN);
39
+ return null;
40
+ }
41
+ if (stored && stored !== refreshToken) {
42
+ // A concurrent refresh succeeded; reuse the freshly stored access token.
43
+ return Storage.Get(StorageKey.ACCESS_TOKEN);
44
+ }
45
+ return null;
46
+ }
47
+ Storage.Set(StorageKey.ACCESS_TOKEN, data.access_token);
48
+ if (data.refresh_token) {
49
+ Storage.Set(StorageKey.REFRESH_TOKEN, data.refresh_token);
50
+ }
51
+ if (data.id_token) {
52
+ Storage.Set(StorageKey.ID_TOKEN, data.id_token);
53
+ }
54
+ return data.access_token;
55
+ }
56
+ async Refresh() {
57
+ if (!this.refreshPromise) {
58
+ this.refreshPromise = this.doRefresh();
59
+ }
60
+ let token = null;
61
+ try {
62
+ token = await this.refreshPromise;
63
+ }
64
+ catch (e) {
65
+ console.error(e);
66
+ }
67
+ finally {
68
+ this.refreshPromise = null;
69
+ }
70
+ if (!token) {
71
+ return null;
72
+ }
73
+ return token;
74
+ }
75
+ RequestWithAccessToken(req, token = null) {
76
+ token ??= Storage.Get(StorageKey.ACCESS_TOKEN);
77
+ if (token) {
78
+ req.headers.set('Authorization', `Bearer ${token}`);
79
+ }
80
+ return req;
81
+ }
82
+ }
@@ -0,0 +1,3 @@
1
+ import { BaseAuthService } from "../../../v2/auth/service/baseService.js";
2
+ import { AuthService } from "../../../v2/auth/service/service.js";
3
+ export { AuthService, BaseAuthService };
@@ -0,0 +1,3 @@
1
+ import { BaseAuthService } from "../../../v2/auth/service/baseService.js";
2
+ import { AuthService } from "../../../v2/auth/service/service.js";
3
+ export { AuthService, BaseAuthService };
@@ -0,0 +1,19 @@
1
+ import { BaseAuthService, GetMeResponse, HandleCallbackResult, HttpAuthClient, AuthService as IAuthService, ZitadelConfig } from "../../../v2/pkg/types/index.js";
2
+ import { components } from "@dehwyyy/auth/v2/pkg/types/schema";
3
+ type UserInfo = components["schemas"]["UserInfo"];
4
+ export declare class AuthService implements IAuthService {
5
+ private baseAuthService;
6
+ private authClient;
7
+ private config;
8
+ private issuer;
9
+ private callbackByCode;
10
+ constructor(baseAuthService: BaseAuthService, authClient: HttpAuthClient, config: ZitadelConfig);
11
+ private scopes;
12
+ Login(returnTo?: string): Promise<void>;
13
+ HandleCallback(): Promise<HandleCallbackResult>;
14
+ private exchange;
15
+ GetMe(verbose?: boolean): Promise<GetMeResponse | null>;
16
+ Logout(): Promise<void>;
17
+ static mapUserInfo(claims: UserInfo, verbose: boolean): GetMeResponse;
18
+ }
19
+ export {};
@@ -0,0 +1,162 @@
1
+ import { SessionKey, SessionStore, Storage, StorageKey } from "../../../v2/pkg/localStorage/index.js";
2
+ import { CallbackError } from "../../../v2/pkg/errors/index.js";
3
+ import { challengeS256, generateState, generateVerifier } from "../../../v2/pkg/pkce/index.js";
4
+ import { normalizeIssuer, sanitizeReturnTo } from "../../../v2/pkg/url/index.js";
5
+ const ROLES_CLAIM = "urn:zitadel:iam:org:project:roles";
6
+ const DEFAULT_SCOPES = [
7
+ "openid",
8
+ "profile",
9
+ "email",
10
+ "offline_access",
11
+ "urn:zitadel:iam:org:projects:roles",
12
+ "urn:zitadel:iam:user:metadata",
13
+ ];
14
+ export class AuthService {
15
+ baseAuthService;
16
+ authClient;
17
+ config;
18
+ issuer;
19
+ callbackByCode = new Map();
20
+ constructor(baseAuthService, authClient, config) {
21
+ this.baseAuthService = baseAuthService;
22
+ this.authClient = authClient;
23
+ this.config = config;
24
+ this.issuer = normalizeIssuer(config.issuer);
25
+ }
26
+ scopes() {
27
+ return (this.config.scopes ?? DEFAULT_SCOPES).join(" ");
28
+ }
29
+ async Login(returnTo) {
30
+ const verifier = generateVerifier();
31
+ const state = generateState();
32
+ const challenge = await challengeS256(verifier);
33
+ SessionStore.Set(SessionKey.PKCE_VERIFIER, verifier);
34
+ SessionStore.Set(SessionKey.PKCE_STATE, state);
35
+ if (returnTo) {
36
+ SessionStore.Set(SessionKey.RETURN_TO, returnTo);
37
+ }
38
+ else {
39
+ SessionStore.Delete(SessionKey.RETURN_TO);
40
+ }
41
+ const params = new URLSearchParams({
42
+ client_id: this.config.clientId,
43
+ redirect_uri: this.config.redirectUri,
44
+ response_type: "code",
45
+ scope: this.scopes(),
46
+ code_challenge: challenge,
47
+ code_challenge_method: "S256",
48
+ state,
49
+ });
50
+ location.assign(`${this.issuer}/oauth/v2/authorize?${params.toString()}`);
51
+ }
52
+ HandleCallback() {
53
+ const search = new URLSearchParams(location.search);
54
+ const code = search.get("code");
55
+ if (!code) {
56
+ return Promise.reject(new CallbackError("code_missing", "authorization code missing in callback"));
57
+ }
58
+ // Single-flight per authorization code: a double invocation (Vue dev double
59
+ // mount, manual refresh) reuses the in-flight/resolved exchange instead of
60
+ // re-running it and tripping a "state mismatch" on cleared session state.
61
+ const existing = this.callbackByCode.get(code);
62
+ if (existing) {
63
+ return existing;
64
+ }
65
+ const promise = this.exchange(code, search.get("state"));
66
+ this.callbackByCode.set(code, promise);
67
+ return promise;
68
+ }
69
+ async exchange(code, state) {
70
+ const storedState = SessionStore.Get(SessionKey.PKCE_STATE);
71
+ const verifier = SessionStore.Get(SessionKey.PKCE_VERIFIER);
72
+ const returnTo = sanitizeReturnTo(SessionStore.Get(SessionKey.RETURN_TO));
73
+ try {
74
+ if (!state || state !== storedState) {
75
+ throw new CallbackError("state_mismatch", "state mismatch in callback");
76
+ }
77
+ if (!verifier) {
78
+ throw new CallbackError("verifier_missing", "pkce verifier missing in callback");
79
+ }
80
+ const body = new URLSearchParams({
81
+ grant_type: "authorization_code",
82
+ code,
83
+ redirect_uri: this.config.redirectUri,
84
+ client_id: this.config.clientId,
85
+ code_verifier: verifier,
86
+ });
87
+ const { data, error } = await this.authClient.POST("/oauth/v2/token", {
88
+ body: body,
89
+ bodySerializer: () => body.toString(),
90
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
91
+ });
92
+ if (error || !data?.access_token) {
93
+ throw new CallbackError("exchange_failed", "token exchange failed");
94
+ }
95
+ Storage.Set(StorageKey.ACCESS_TOKEN, data.access_token);
96
+ if (data.refresh_token) {
97
+ Storage.Set(StorageKey.REFRESH_TOKEN, data.refresh_token);
98
+ }
99
+ if (data.id_token) {
100
+ Storage.Set(StorageKey.ID_TOKEN, data.id_token);
101
+ }
102
+ const me = await this.GetMe(true);
103
+ return { me, returnTo };
104
+ }
105
+ finally {
106
+ SessionStore.Delete(SessionKey.PKCE_VERIFIER);
107
+ SessionStore.Delete(SessionKey.PKCE_STATE);
108
+ SessionStore.Delete(SessionKey.RETURN_TO);
109
+ }
110
+ }
111
+ async GetMe(verbose = false) {
112
+ let token = Storage.Get(StorageKey.ACCESS_TOKEN);
113
+ if (!token) {
114
+ token = await this.baseAuthService.Refresh();
115
+ if (!token) {
116
+ return null;
117
+ }
118
+ }
119
+ const { data, error } = await this.authClient.GET("/oidc/v1/userinfo");
120
+ if (error) {
121
+ return null;
122
+ }
123
+ if (!data || !data.sub) {
124
+ return null;
125
+ }
126
+ return AuthService.mapUserInfo(data, verbose);
127
+ }
128
+ async Logout() {
129
+ const idToken = Storage.Get(StorageKey.ID_TOKEN);
130
+ Storage.Delete(StorageKey.ACCESS_TOKEN);
131
+ Storage.Delete(StorageKey.REFRESH_TOKEN);
132
+ Storage.Delete(StorageKey.ID_TOKEN);
133
+ const params = new URLSearchParams();
134
+ if (idToken) {
135
+ params.set("id_token_hint", idToken);
136
+ }
137
+ const postLogout = this.config.postLogoutRedirectUri;
138
+ if (postLogout) {
139
+ params.set("post_logout_redirect_uri", postLogout);
140
+ }
141
+ location.assign(`${this.issuer}/oidc/v1/end_session?${params.toString()}`);
142
+ }
143
+ static mapUserInfo(claims, verbose) {
144
+ const rolesClaim = claims[ROLES_CLAIM] ?? {};
145
+ const roles = Object.keys(rolesClaim);
146
+ const me = {
147
+ user_id: claims.sub,
148
+ roles,
149
+ active: true,
150
+ };
151
+ if (verbose) {
152
+ me.info = {
153
+ username: claims.preferred_username,
154
+ email: claims.email,
155
+ email_verified: claims.email_verified,
156
+ name: claims.name,
157
+ picture: claims.picture,
158
+ };
159
+ }
160
+ return me;
161
+ }
162
+ }
@@ -0,0 +1,7 @@
1
+ import createClient, { ClientOptions, Middleware as OpenAPIMiddleware } from "openapi-fetch";
2
+ export declare class ClientBuilder<OpenAPIClientDefinitions extends {}> {
3
+ private middlewares;
4
+ constructor();
5
+ withMiddleware(...middlewares: OpenAPIMiddleware[]): this;
6
+ build(opts?: ClientOptions): ReturnType<typeof createClient<OpenAPIClientDefinitions>>;
7
+ }
@@ -0,0 +1,14 @@
1
+ import createClient from "openapi-fetch";
2
+ export class ClientBuilder {
3
+ middlewares = [];
4
+ constructor() { }
5
+ withMiddleware(...middlewares) {
6
+ this.middlewares = this.middlewares.concat(middlewares);
7
+ return this;
8
+ }
9
+ build(opts) {
10
+ const client = createClient(opts);
11
+ client.use(...this.middlewares);
12
+ return client;
13
+ }
14
+ }
@@ -0,0 +1,2 @@
1
+ import { ClientBuilder } from "../../../v2/client/builder/builder.js";
2
+ export { ClientBuilder };
@@ -0,0 +1,2 @@
1
+ import { ClientBuilder } from "../../../v2/client/builder/builder.js";
2
+ export { ClientBuilder };
@@ -0,0 +1,7 @@
1
+ import createClient from "openapi-fetch";
2
+ type Client = ReturnType<typeof createClient<{}>>;
3
+ export declare class ClientInstance {
4
+ private static map;
5
+ New<T extends Client>(key: string, clientFactory: () => T): T;
6
+ }
7
+ export {};