@quazardous/qdadm 2.16.1 → 2.17.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.16.1",
3
+ "version": "2.17.1",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -25,7 +25,7 @@
25
25
  "format": "prettier --write src/",
26
26
  "build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
27
27
  "build:node": "tsc -p tsconfig.node.json",
28
- "prepack": "npm run build:node"
28
+ "prepare": "npm run build:node"
29
29
  },
30
30
  "exports": {
31
31
  ".": "./src/index.ts",
@@ -54,7 +54,7 @@
54
54
  "dist/node"
55
55
  ],
56
56
  "dependencies": {
57
- "@quazardous/qdcore": "^1.1.0",
57
+ "@quazardous/qdcore": "^1.1.1",
58
58
  "@quazardous/qddebug": "^1.1.0",
59
59
  "@quazardous/quarkernel": "^2.1.0",
60
60
  "yaml": "^2.8.4"
package/src/auth/index.ts CHANGED
@@ -13,3 +13,12 @@ export {
13
13
  type ISessionAuthAdapter,
14
14
  type LocalStorageAuthConfig,
15
15
  } from './SessionAuthAdapter'
16
+
17
+ export {
18
+ GoogleOAuthAdapter,
19
+ createGoogleOAuthAdapter,
20
+ createPkceChallenge,
21
+ type GoogleOAuthAdapterOptions,
22
+ type OAuthExchangePayload,
23
+ type PkceChallenge,
24
+ } from './oauth/index'
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Google sign-in for qdadm, authorization-code + PKCE (#1775).
3
+ *
4
+ * ## The one rule this class exists to enforce
5
+ *
6
+ * qdadm **never** validates a Google credential in the browser. Decoding a JWT
7
+ * client-side proves nothing: anyone can mint one. The authorization code is
8
+ * therefore useless to the front — it is posted to *your* backend, which
9
+ * exchanges it with Google using the client secret, verifies the identity, and
10
+ * issues **its own** session. That session token is the only thing this adapter
11
+ * ever stores.
12
+ *
13
+ * There is deliberately no code path that turns a Google response into a
14
+ * session without your backend. That is a structural constraint, not advice.
15
+ *
16
+ * ## The backend contract is open
17
+ *
18
+ * `exchangeUrl` receives a POST and returns `{ token, user }`. Nothing about it
19
+ * is JavaScript: implement it in Python, Go, PHP, anything. Because the
20
+ * contract is open, configuration is enough for the common case —
21
+ *
22
+ * ```ts
23
+ * new GoogleOAuthAdapter({
24
+ * clientId: '…apps.googleusercontent.com',
25
+ * exchangeUrl: '/auth/google/exchange',
26
+ * })
27
+ * ```
28
+ *
29
+ * — and an app with a Python backend writes no adapter code at all. Override
30
+ * `exchange()` only when your endpoint cannot match the documented shape.
31
+ *
32
+ * See docs/auth-google.md for the wire contract and a reference backend.
33
+ *
34
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
35
+ */
36
+
37
+ import { LocalStorageSessionAuthAdapter, type AuthUser, type SessionData } from '../SessionAuthAdapter'
38
+ import { createPkceChallenge, storeAttempt, takeAttempt, type StoredAttempt } from './pkce'
39
+
40
+ /** What the front sends your backend. */
41
+ export interface OAuthExchangePayload {
42
+ /** The authorization code the provider handed back. */
43
+ code: string
44
+ /** The PKCE verifier that proves this code belongs to this browser. */
45
+ codeVerifier: string
46
+ /** Echoed so the backend can pin the exchange to the same redirect. */
47
+ redirectUri: string
48
+ }
49
+
50
+ export interface GoogleOAuthAdapterOptions {
51
+ /** OAuth client id. Not a secret — the client SECRET stays on your backend. */
52
+ clientId: string
53
+ /**
54
+ * Your endpoint that trades the code for a session. POSTed
55
+ * `OAuthExchangePayload`, expected to answer `{ token, user }`.
56
+ */
57
+ exchangeUrl?: string
58
+ /**
59
+ * Where the provider redirects back. Must match a URI registered in the
60
+ * provider console **exactly** — that is the single most common setup
61
+ * failure. Defaults to `/auth/google/callback` on the current origin.
62
+ */
63
+ redirectUri?: string
64
+ /** Scopes requested. Defaults to identity only. */
65
+ scopes?: string[]
66
+ /**
67
+ * Ask for a refresh token. Requires `prompt: 'consent'` on Google to be
68
+ * issued reliably, and the token stays with your backend — the front never
69
+ * sees it.
70
+ */
71
+ offlineAccess?: boolean
72
+ /** localStorage key for the session (see ADR 0008 on shared origins). */
73
+ storageKey?: string
74
+ /** Authorization endpoint. Overridable for tests and for other providers. */
75
+ authorizeEndpoint?: string
76
+ }
77
+
78
+ const GOOGLE_AUTHORIZE = 'https://accounts.google.com/o/oauth2/v2/auth'
79
+
80
+ export class GoogleOAuthAdapter<TUser extends AuthUser = AuthUser>
81
+ extends LocalStorageSessionAuthAdapter<TUser> {
82
+ protected _clientId: string
83
+ protected _exchangeUrl: string | null
84
+ protected _redirectUri: string
85
+ protected _scopes: string[]
86
+ protected _offlineAccess: boolean
87
+ protected _authorizeEndpoint: string
88
+
89
+ constructor(options: GoogleOAuthAdapterOptions) {
90
+ super({ storageKey: options.storageKey ?? 'qdadm_auth' })
91
+
92
+ if (!options?.clientId) {
93
+ throw new Error('[GoogleOAuthAdapter] clientId is required')
94
+ }
95
+
96
+ this._clientId = options.clientId
97
+ this._exchangeUrl = options.exchangeUrl ?? null
98
+ this._redirectUri =
99
+ options.redirectUri ??
100
+ (typeof window !== 'undefined'
101
+ ? `${window.location.origin}/auth/google/callback`
102
+ : '/auth/google/callback')
103
+ this._scopes = options.scopes ?? ['openid', 'email', 'profile']
104
+ this._offlineAccess = options.offlineAccess ?? false
105
+ this._authorizeEndpoint = options.authorizeEndpoint ?? GOOGLE_AUTHORIZE
106
+ }
107
+
108
+ /** The redirect URI this adapter will use — handy for error messages. */
109
+ get redirectUri(): string {
110
+ return this._redirectUri
111
+ }
112
+
113
+ /**
114
+ * Build the authorization URL for a fresh attempt.
115
+ *
116
+ * Stashes the PKCE verifier, the CSRF state and where the user was heading,
117
+ * so the callback can finish what this started.
118
+ */
119
+ async buildAuthorizeUrl(redirectTo = '/'): Promise<string> {
120
+ const { verifier, challenge, state } = await createPkceChallenge()
121
+ storeAttempt({ verifier, state, redirectTo })
122
+
123
+ const url = new URL(this._authorizeEndpoint)
124
+ url.searchParams.set('client_id', this._clientId)
125
+ url.searchParams.set('redirect_uri', this._redirectUri)
126
+ url.searchParams.set('response_type', 'code')
127
+ url.searchParams.set('scope', this._scopes.join(' '))
128
+ url.searchParams.set('state', state)
129
+ url.searchParams.set('code_challenge', challenge)
130
+ url.searchParams.set('code_challenge_method', 'S256')
131
+ if (this._offlineAccess) {
132
+ url.searchParams.set('access_type', 'offline')
133
+ // Google only re-issues a refresh token when consent is asked again.
134
+ url.searchParams.set('prompt', 'consent')
135
+ }
136
+
137
+ return url.toString()
138
+ }
139
+
140
+ /** Send the browser to the provider. */
141
+ async beginLogin(redirectTo = '/'): Promise<void> {
142
+ window.location.assign(await this.buildAuthorizeUrl(redirectTo))
143
+ }
144
+
145
+ /**
146
+ * Trade the code for a session **on your backend**.
147
+ *
148
+ * Override this when your endpoint's shape differs. Whatever you do, the
149
+ * exchange must happen server-side: the browser has no client secret and
150
+ * cannot verify anything Google says.
151
+ */
152
+ async exchange(payload: OAuthExchangePayload): Promise<SessionData<TUser>> {
153
+ if (!this._exchangeUrl) {
154
+ throw new Error(
155
+ '[GoogleOAuthAdapter] no exchangeUrl configured, and exchange() was not overridden. ' +
156
+ 'The authorization code must be redeemed by your backend — the browser cannot do it.'
157
+ )
158
+ }
159
+
160
+ const response = await fetch(this._exchangeUrl, {
161
+ method: 'POST',
162
+ headers: { 'content-type': 'application/json' },
163
+ credentials: 'same-origin',
164
+ body: JSON.stringify(payload),
165
+ })
166
+
167
+ if (!response.ok) {
168
+ const detail = await response.text().catch(() => '')
169
+ throw new Error(
170
+ `[GoogleOAuthAdapter] exchange failed (${response.status})${detail ? `: ${detail.slice(0, 200)}` : ''}`
171
+ )
172
+ }
173
+
174
+ const data = (await response.json()) as SessionData<TUser>
175
+ if (!data?.token || !data?.user) {
176
+ throw new Error(
177
+ '[GoogleOAuthAdapter] exchange endpoint must return { token, user } — see docs/auth-google.md'
178
+ )
179
+ }
180
+ return data
181
+ }
182
+
183
+ /**
184
+ * Finish the login from the callback route's query string.
185
+ *
186
+ * @returns the session plus where to send the user next.
187
+ * @throws when the provider reported an error, when no attempt is pending,
188
+ * or when `state` does not match — each of which is a reason to refuse.
189
+ */
190
+ async completeLogin(
191
+ query: URLSearchParams | Record<string, string | undefined>
192
+ ): Promise<SessionData<TUser> & { redirectTo: string }> {
193
+ const read = (key: string): string | undefined =>
194
+ query instanceof URLSearchParams ? (query.get(key) ?? undefined) : query[key]
195
+
196
+ const providerError = read('error')
197
+ if (providerError) {
198
+ throw new Error(`[GoogleOAuthAdapter] provider refused the login: ${providerError}`)
199
+ }
200
+
201
+ const attempt: StoredAttempt | null = takeAttempt()
202
+ if (!attempt) {
203
+ // No pending attempt: a replayed or bookmarked callback URL. Refusing is
204
+ // the point — the attempt is consumed on read.
205
+ throw new Error('[GoogleOAuthAdapter] no login attempt pending for this callback')
206
+ }
207
+
208
+ const state = read('state')
209
+ if (!state || state !== attempt.state) {
210
+ throw new Error('[GoogleOAuthAdapter] state mismatch — refusing the callback')
211
+ }
212
+
213
+ const code = read('code')
214
+ if (!code) {
215
+ throw new Error('[GoogleOAuthAdapter] callback carried no authorization code')
216
+ }
217
+
218
+ const session = await this.exchange({
219
+ code,
220
+ codeVerifier: attempt.verifier,
221
+ redirectUri: this._redirectUri,
222
+ })
223
+
224
+ this.setSession(session.token, session.user)
225
+ this.persist()
226
+
227
+ return { ...session, redirectTo: attempt.redirectTo || '/' }
228
+ }
229
+ }
230
+
231
+ export function createGoogleOAuthAdapter<TUser extends AuthUser = AuthUser>(
232
+ options: GoogleOAuthAdapterOptions
233
+ ): GoogleOAuthAdapter<TUser> {
234
+ return new GoogleOAuthAdapter<TUser>(options)
235
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * OAuth sign-in helpers (#1775).
3
+ *
4
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
5
+ *
6
+ * @module auth/oauth
7
+ */
8
+
9
+ export {
10
+ GoogleOAuthAdapter,
11
+ createGoogleOAuthAdapter,
12
+ type GoogleOAuthAdapterOptions,
13
+ type OAuthExchangePayload,
14
+ } from './GoogleOAuthAdapter'
15
+
16
+ export {
17
+ createPkceChallenge,
18
+ storeAttempt,
19
+ takeAttempt,
20
+ clearAttempt,
21
+ type PkceChallenge,
22
+ type StoredAttempt,
23
+ } from './pkce'
@@ -0,0 +1,96 @@
1
+ /**
2
+ * PKCE and CSRF state for the OAuth authorization-code flow (#1775).
3
+ *
4
+ * No dependency: `crypto.getRandomValues` and `crypto.subtle` exist in every
5
+ * secure context, and `localhost` counts as one.
6
+ *
7
+ * Why both matter, since they are often confused:
8
+ *
9
+ * - **PKCE** protects the authorization code while it travels through the
10
+ * browser. The code lands on a front-end route and is then posted to your
11
+ * backend; without the verifier, whoever intercepts it can redeem it.
12
+ * - **`state`** is CSRF protection for the redirect itself: it proves the
13
+ * callback being handled belongs to a login *this tab* started.
14
+ *
15
+ * Both live in `sessionStorage`, never `localStorage`: they are single-use,
16
+ * they belong to one tab, and they must not outlive it.
17
+ *
18
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
19
+ */
20
+
21
+ /** What one login attempt must remember across the redirect. */
22
+ export interface PkceChallenge {
23
+ /** Kept locally, sent to your backend at exchange time — never to Google. */
24
+ verifier: string
25
+ /** Sent to the provider in the authorize URL. */
26
+ challenge: string
27
+ /** CSRF nonce echoed back by the provider. */
28
+ state: string
29
+ }
30
+
31
+ /** The pending attempt, recovered when the provider redirects back. */
32
+ export interface StoredAttempt {
33
+ verifier: string
34
+ state: string
35
+ /** Where the user was heading before being sent to the login page. */
36
+ redirectTo: string
37
+ }
38
+
39
+ const STORAGE_KEY = 'qdadm_oauth_attempt'
40
+
41
+ /** URL-safe base64 without padding, as RFC 7636 requires. */
42
+ function base64Url(bytes: ArrayBuffer | Uint8Array): string {
43
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
44
+ let binary = ''
45
+ for (const byte of view) binary += String.fromCharCode(byte)
46
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
47
+ }
48
+
49
+ function randomString(byteLength = 32): string {
50
+ const bytes = new Uint8Array(byteLength)
51
+ crypto.getRandomValues(bytes)
52
+ return base64Url(bytes)
53
+ }
54
+
55
+ /**
56
+ * Build a fresh verifier / challenge / state triple.
57
+ *
58
+ * S256 only. The spec still allows `plain`, but `plain` sends the verifier to
59
+ * the authorization server, which defeats the point of having one.
60
+ */
61
+ export async function createPkceChallenge(): Promise<PkceChallenge> {
62
+ const verifier = randomString(32)
63
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
64
+
65
+ return { verifier, challenge: base64Url(digest), state: randomString(16) }
66
+ }
67
+
68
+ /** Stash the pending attempt for the trip through the provider. */
69
+ export function storeAttempt(attempt: StoredAttempt): void {
70
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(attempt))
71
+ }
72
+
73
+ /**
74
+ * Recover the pending attempt AND clear it.
75
+ *
76
+ * A login attempt is single-use: a replayed callback URL finds nothing and is
77
+ * rejected, which is half of what `state` is for.
78
+ */
79
+ export function takeAttempt(): StoredAttempt | null {
80
+ const raw = sessionStorage.getItem(STORAGE_KEY)
81
+ sessionStorage.removeItem(STORAGE_KEY)
82
+ if (!raw) return null
83
+
84
+ try {
85
+ const parsed = JSON.parse(raw) as StoredAttempt
86
+ if (!parsed?.verifier || !parsed?.state) return null
87
+ return parsed
88
+ } catch {
89
+ return null
90
+ }
91
+ }
92
+
93
+ /** Drop a pending attempt without consuming it (the user backed out). */
94
+ export function clearAttempt(): void {
95
+ sessionStorage.removeItem(STORAGE_KEY)
96
+ }
@@ -77,6 +77,7 @@ export { default as BannerZone } from './display/BannerZone.vue'
77
77
 
78
78
  // Pages
79
79
  export { default as LoginPage } from './pages/LoginPage.vue'
80
+ export { default as OAuthCallbackPage } from './pages/OAuthCallbackPage.vue'
80
81
  export { default as NotFoundPage } from './pages/NotFoundPage.vue'
81
82
 
82
83
  // Host integration helper — render in a host App.vue when using
@@ -252,6 +252,14 @@ async function handleLogin(): Promise<void> {
252
252
  class="w-full"
253
253
  :loading="loading"
254
254
  />
255
+ <!-- Alternative sign-ins (#1775): SSO buttons go between the form
256
+ and the footer, which is where users look for them. Rendered
257
+ only when filled, so the divider never shows on its own. -->
258
+ <div v-if="$slots.alternatives" class="qdadm-login-alternatives">
259
+ <div class="qdadm-login-divider"><span>or</span></div>
260
+ <slot name="alternatives"></slot>
261
+ </div>
262
+
255
263
  <slot name="footer"></slot>
256
264
  </form>
257
265
  </template>
@@ -260,6 +268,27 @@ async function handleLogin(): Promise<void> {
260
268
  </template>
261
269
 
262
270
  <style scoped>
271
+ .qdadm-login-alternatives {
272
+ display: flex;
273
+ flex-direction: column;
274
+ gap: 0.75rem;
275
+ }
276
+
277
+ .qdadm-login-divider {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.75rem;
281
+ color: var(--p-text-muted-color, #6b7280);
282
+ font-size: 0.875rem;
283
+ }
284
+
285
+ .qdadm-login-divider::before,
286
+ .qdadm-login-divider::after {
287
+ content: '';
288
+ flex: 1;
289
+ border-top: 1px solid var(--p-content-border-color, #e5e7eb);
290
+ }
291
+
263
292
  /*
264
293
  * Only keep styles here that REQUIRE scoping (:deep, dynamic binding, component-specific overrides).
265
294
  * Generic/reusable styles belong in src/styles/ partials (see _forms.scss, _cards.scss, etc.).
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * OAuth callback landing page (#1775).
4
+ *
5
+ * Register this on a **public** route. `Kernel.routing.ts:192` sends anyone
6
+ * unauthenticated back to the login page, and at this exact moment the user is
7
+ * *not yet* authenticated — a guarded callback route bounces the provider's
8
+ * redirect straight back to login, which is the classic way this flow fails
9
+ * and gives no clue why:
10
+ *
11
+ * ```js
12
+ * ctx.routes('/auth/google', [{
13
+ * path: 'callback',
14
+ * name: 'google-callback',
15
+ * component: () => import('@quazardous/qdadm/components/OAuthCallbackPage.vue'),
16
+ * meta: { public: true }, // ← without this, nothing works
17
+ * }])
18
+ * ```
19
+ *
20
+ * The page itself does almost nothing: it hands the query string to the
21
+ * adapter, which validates `state`, has your backend redeem the code, and
22
+ * stores the session your backend issued. Then it goes where the user was
23
+ * headed before login sent them away.
24
+ *
25
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
26
+ */
27
+ import { ref, inject, onMounted } from 'vue'
28
+ import { useRoute, useRouter } from 'vue-router'
29
+ import QdButton from '../base/QdButton.vue'
30
+
31
+ interface OAuthCapableAdapter {
32
+ completeLogin: (
33
+ query: Record<string, string | undefined>
34
+ ) => Promise<{ user?: unknown; redirectTo: string }>
35
+ }
36
+
37
+ interface SignalsLike {
38
+ emit?: (signal: string, payload?: unknown) => unknown
39
+ }
40
+
41
+ const props = defineProps({
42
+ /** Where to send the user when the callback carries no destination. */
43
+ fallbackRedirect: { type: String, default: '/' },
44
+ /** Route to offer on failure. */
45
+ loginRoute: { type: String, default: '/login' },
46
+ /** Shown while the exchange is in flight. */
47
+ pendingLabel: { type: String, default: 'Signing you in…' },
48
+ })
49
+
50
+ const emit = defineEmits<{
51
+ (e: 'success', payload: unknown): void
52
+ (e: 'error', error: unknown): void
53
+ }>()
54
+
55
+ const route = useRoute()
56
+ const router = useRouter()
57
+ const authAdapter = inject<OAuthCapableAdapter | null>('authAdapter', null)
58
+ const signals = inject<SignalsLike | null>('qdadmSignals', null)
59
+
60
+ const error = ref<string | null>(null)
61
+
62
+ onMounted(async () => {
63
+ if (typeof authAdapter?.completeLogin !== 'function') {
64
+ error.value =
65
+ 'No OAuth-capable auth adapter is configured. The kernel needs an adapter exposing completeLogin().'
66
+ emit('error', new Error(error.value))
67
+ return
68
+ }
69
+
70
+ try {
71
+ const query = Object.fromEntries(
72
+ Object.entries(route.query).map(([key, value]) => [
73
+ key,
74
+ Array.isArray(value) ? value[0] : value,
75
+ ])
76
+ ) as Record<string, string | undefined>
77
+
78
+ const result = await authAdapter.completeLogin(query)
79
+
80
+ // Same signal the password form emits, so debug bar, SSE bridge and any
81
+ // listener see one authentication event whichever door was used.
82
+ signals?.emit?.('auth:login', { user: result.user })
83
+ emit('success', result)
84
+
85
+ await router.replace(result.redirectTo || props.fallbackRedirect)
86
+ } catch (err) {
87
+ error.value = (err as Error)?.message ?? 'Sign-in failed'
88
+ signals?.emit?.('auth:login:error', { error: error.value })
89
+ emit('error', err)
90
+ }
91
+ })
92
+ </script>
93
+
94
+ <template>
95
+ <div class="qdadm-oauth-callback">
96
+ <template v-if="error">
97
+ <i class="pi pi-times-circle qdadm-oauth-icon qdadm-oauth-icon-error" />
98
+ <p class="qdadm-oauth-message">{{ error }}</p>
99
+ <QdButton label="Back to sign in" icon="pi pi-arrow-left" @click="router.replace(loginRoute)" />
100
+ </template>
101
+ <template v-else>
102
+ <i class="pi pi-spin pi-spinner qdadm-oauth-icon" />
103
+ <p class="qdadm-oauth-message">{{ pendingLabel }}</p>
104
+ </template>
105
+ </div>
106
+ </template>
107
+
108
+ <style scoped>
109
+ .qdadm-oauth-callback {
110
+ display: flex;
111
+ flex-direction: column;
112
+ align-items: center;
113
+ justify-content: center;
114
+ gap: 1rem;
115
+ min-height: 60vh;
116
+ padding: 2rem;
117
+ text-align: center;
118
+ }
119
+
120
+ .qdadm-oauth-icon {
121
+ font-size: 2rem;
122
+ color: var(--p-primary-color, #10b981);
123
+ }
124
+
125
+ .qdadm-oauth-icon-error {
126
+ color: var(--p-red-500, #ef4444);
127
+ }
128
+
129
+ .qdadm-oauth-message {
130
+ margin: 0;
131
+ color: var(--p-text-muted-color, #6b7280);
132
+ max-width: 34rem;
133
+ }
134
+ </style>
package/src/index.ts CHANGED
@@ -164,6 +164,12 @@ export * from './entity/auth'
164
164
  // SESSION AUTH (user authentication)
165
165
  // ════════════════════════════════════════════════════════════════════════════
166
166
  export { LocalStorageSessionAuthAdapter } from './auth'
167
+ export { GoogleOAuthAdapter, createGoogleOAuthAdapter, createPkceChallenge } from './auth'
168
+ export type {
169
+ GoogleOAuthAdapterOptions,
170
+ OAuthExchangePayload,
171
+ PkceChallenge,
172
+ } from './auth'
167
173
  export type {
168
174
  LoginCredentials,
169
175
  SessionData,