@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 +3 -3
- package/src/auth/index.ts +9 -0
- package/src/auth/oauth/GoogleOAuthAdapter.ts +235 -0
- package/src/auth/oauth/index.ts +23 -0
- package/src/auth/oauth/pkce.ts +96 -0
- package/src/components/index.ts +1 -0
- package/src/components/pages/LoginPage.vue +29 -0
- package/src/components/pages/OAuthCallbackPage.vue +134 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
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
|
-
"
|
|
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.
|
|
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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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,
|