Files
Ronni Skansing df555820f9 Add OIDC provider support.
Add exclusive SSO login support.

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-06-17 23:41:03 +02:00

182 lines
5.5 KiB
Go

package sso
import (
"context"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-errors/errors"
"golang.org/x/oauth2"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
)
// oidcNetworkTimeout bounds the outbound calls to the identity provider
// (discovery, token exchange and JWKS) so a slow or unreachable provider cannot
// hang a request goroutine.
const oidcNetworkTimeout = 15 * time.Second
// OIDCClient is a configured generic OpenID Connect relying party. It holds the
// discovered provider, the oauth2 config and the ID token verifier so logins and
// callbacks can be served without re running discovery each request.
type OIDCClient struct {
Provider *oidc.Provider
OAuth2 *oauth2.Config
Verifier *oidc.IDTokenVerifier
ACRValues string
}
// OIDCUserInfo holds the identity claims taken from a verified ID token.
type OIDCUserInfo struct {
Subject string
Email string
EmailVerified bool
Name string
}
// NewOIDCClient discovers the provider and builds the relying party from the SSO
// configuration. Discovery performs a network request to the issuer.
func NewOIDCClient(ctx context.Context, sso *model.SSOOption) (*OIDCClient, error) {
if !sso.Enabled {
return nil, errs.Wrap(errs.ErrSSODisabled)
}
issuer := strings.TrimSpace(sso.IssuerURL.String())
if issuer == "" {
return nil, errs.Wrap(errors.New("missing OIDC issuer URL"))
}
clientID := sso.ClientID.String()
ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
defer cancel()
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, errs.Wrap(errors.Errorf("failed to discover OIDC provider: %s", err))
}
oauth2Config := &oauth2.Config{
ClientID: clientID,
ClientSecret: sso.ClientSecret.String(),
Endpoint: provider.Endpoint(),
RedirectURL: sso.RedirectURL.String(),
Scopes: strings.Fields(sso.ScopesOrDefault()),
}
verifier := provider.Verifier(&oidc.Config{
ClientID: clientID,
})
return &OIDCClient{
Provider: provider,
OAuth2: oauth2Config,
Verifier: verifier,
ACRValues: strings.TrimSpace(sso.ACRValues.String()),
}, nil
}
// AuthCodeURL builds the authorization request URL. The nonce binds the ID token
// to this login, PKCE protects the code exchange and acr_values requests a given
// authentication context such as multi factor authentication.
func (c *OIDCClient) AuthCodeURL(state string, nonce string, verifier string) string {
opts := []oauth2.AuthCodeOption{
oidc.Nonce(nonce),
oauth2.S256ChallengeOption(verifier),
}
if c.ACRValues != "" {
opts = append(opts, oauth2.SetAuthURLParam("acr_values", c.ACRValues))
}
return c.OAuth2.AuthCodeURL(state, opts...)
}
// Exchange swaps the authorization code for tokens, verifies the ID token and
// returns the identity claims. It enforces the nonce, that the email is verified
// and, when configured, that the returned authentication context matches.
func (c *OIDCClient) Exchange(
ctx context.Context,
code string,
nonce string,
verifier string,
) (*OIDCUserInfo, error) {
ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
defer cancel()
token, err := c.OAuth2.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
return nil, errs.Wrap(err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, errs.Wrap(errors.New("no id_token in token response"))
}
idToken, err := c.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errs.Wrap(err)
}
if idToken.Nonce != nonce {
return nil, errs.Wrap(errors.New("OIDC nonce mismatch"))
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified flexBool `json:"email_verified"`
Name string `json:"name"`
ACR string `json:"acr"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, errs.Wrap(err)
}
if claims.Email == "" {
return nil, errs.Wrap(errs.ErrSSONoEmail)
}
// a generic provider can hand out self set, unverified emails so the email
// is only trusted for account matching when the provider has verified it
if !bool(claims.EmailVerified) {
return nil, errs.Wrap(errs.ErrSSOEmailNotVerified)
}
if c.ACRValues != "" && !acrSatisfied(claims.ACR, c.ACRValues) {
return nil, errs.Wrap(fmt.Errorf(
"%w (provider returned acr %q, required one of %q)",
errs.ErrSSOMFARequired, claims.ACR, c.ACRValues,
))
}
return &OIDCUserInfo{
Subject: claims.Subject,
Email: claims.Email,
EmailVerified: bool(claims.EmailVerified),
Name: claims.Name,
}, nil
}
// NewPKCEVerifier returns a fresh high entropy PKCE code verifier (RFC 7636).
func NewPKCEVerifier() string {
return oauth2.GenerateVerifier()
}
// acrSatisfied reports whether the acr returned by the provider is one of the
// space separated values that were requested.
func acrSatisfied(returned string, requested string) bool {
returned = strings.TrimSpace(returned)
if returned == "" {
return false
}
for _, want := range strings.Fields(requested) {
if returned == want {
return true
}
}
return false
}
// flexBool accepts the email_verified claim as either a JSON boolean or a
// quoted string, since providers differ, and fails closed on anything else.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
switch strings.Trim(string(data), `"`) {
case "true":
*b = true
case "false", "", "null":
*b = false
default:
return fmt.Errorf("invalid boolean value for claim: %s", string(data))
}
return nil
}