Compare commits

...

4 Commits

Author SHA1 Message Date
kolaente f24b15c6e9 fix: pass PKCE code_verifier to OIDC provider during token exchange
The frontend sends the code_verifier in the callback request, but the
backend was not forwarding it to the OIDC provider's token endpoint.
This caused Dex (and any PKCE-aware provider) to reject the token
exchange with "Expecting parameter code_verifier in PKCE flow."
2026-04-03 18:20:39 +02:00
kolaente 4c565537e4 feat: send PKCE code_verifier during OIDC token exchange
Retrieve the stored code_verifier from sessionStorage and include it
in the callback POST body so the backend can verify the PKCE challenge.

Ref: #2410
2026-04-02 18:55:31 +02:00
kolaente f5024e2f2c feat: include PKCE code_challenge in OIDC auth redirect
Generate a code_verifier, compute its SHA-256 code_challenge, store
the verifier in sessionStorage, and append code_challenge +
code_challenge_method=S256 to the authorization URL.

Ref: #2410
2026-04-02 18:55:21 +02:00
kolaente fb8e4ea741 feat: add PKCE utility functions for OIDC auth
Add generateCodeVerifier() and generateCodeChallenge() helpers that
implement RFC 7636 PKCE using the Web Crypto API.

Ref: #2410
2026-04-02 18:55:04 +02:00
4 changed files with 50 additions and 6 deletions

View File

@ -0,0 +1,30 @@
/**
* Generate a cryptographically random code_verifier (43-128 chars, RFC 7636 Section 4.1).
* Uses unreserved characters: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
*/
export function generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return base64UrlEncode(array)
}
/**
* Compute code_challenge = BASE64URL(SHA256(code_verifier)) (RFC 7636 Section 4.2).
*/
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return base64UrlEncode(new Uint8Array(digest))
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}

View File

@ -1,4 +1,5 @@
import {createRandomID} from '@/helpers/randomId'
import {generateCodeVerifier, generateCodeChallenge} from '@/helpers/pkce'
import type {IProvider} from '@/types/IProvider'
import {parseURL} from 'ufo'
@ -9,17 +10,21 @@ export function getRedirectUrlFromCurrentFrontendPath(provider: IProvider): stri
return `${url.protocol}//${url.host}/auth/openid/${provider.key}`
}
export const redirectToProvider = (provider: IProvider) => {
export const redirectToProvider = async (provider: IProvider) => {
const redirectUrl = getRedirectUrlFromCurrentFrontendPath(provider)
const state = createRandomID(24)
localStorage.setItem('state', state)
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
sessionStorage.setItem('pkceCodeVerifier', codeVerifier)
let scope = 'openid email profile'
if (provider.scope !== null){
scope = provider.scope
}
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
}
export const redirectToProviderOnLogout = (provider: IProvider) => {

View File

@ -243,9 +243,13 @@ export const useAuthStore = defineStore('auth', () => {
const fullProvider: IProvider = configStore.auth.openidConnect.providers.find((p: IProvider) => p.key === provider)
const codeVerifier = sessionStorage.getItem('pkceCodeVerifier')
sessionStorage.removeItem('pkceCodeVerifier')
const data = {
code: code,
redirect_url: getRedirectUrlFromCurrentFrontendPath(fullProvider),
...(codeVerifier && {code_verifier: codeVerifier}),
}
// Delete an eventually preexisting old token

View File

@ -44,9 +44,10 @@ import (
// Callback contains the callback after an auth request was made and redirected
type Callback struct {
Code string `query:"code" json:"code"`
Scope string `query:"scope" json:"scope"`
RedirectURL string `json:"redirect_url"`
Code string `query:"code" json:"code"`
Scope string `query:"scope" json:"scope"`
RedirectURL string `json:"redirect_url"`
CodeVerifier string `json:"code_verifier"`
}
// Provider is the structure of an OpenID Connect provider
@ -468,7 +469,11 @@ func getProviderAndOidcTokens(c *echo.Context) (*Provider, *oauth2.Token, *oidc.
provider.Oauth2Config.RedirectURL = cb.RedirectURL
// Parse the access & ID token
oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code)
var exchangeOpts []oauth2.AuthCodeOption
if cb.CodeVerifier != "" {
exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("code_verifier", cb.CodeVerifier))
}
oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code, exchangeOpts...)
if err != nil {
var rerr *oauth2.RetrieveError
if errors.As(err, &rerr) {