Files
Mako/internal/oidc/oidc.go
T
VeillaxandClaude Sonnet 5.5 e3147597e8
CI / test (push) Successful in 2m9s
Release / release (push) Successful in 2m16s
Rename Nurse to Mako
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:07:43 -06:00

235 lines
7.3 KiB
Go

// Package oidc is the small slice of OpenID Connect Mako needs: the authorization code flow
// with PKCE against one provider (Authentik), using only the standard library.
//
// The ID token is taken straight from the provider's token endpoint over TLS, which is the case
// OpenID Connect Core section 3.1.3.7 allows to skip checking its signature. Its issuer, audience,
// expiry and nonce are still checked.
package oidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/mako/internal/config"
)
// ErrUnavailable means the provider could not be reached or answered nonsense, as opposed to
// rejecting the sign-in.
var ErrUnavailable = errors.New("the sign-in provider is not reachable")
type Client struct {
issuer, id, secret, redirect string
http *http.Client
mu sync.Mutex
disco *Discovery
fetched time.Time
}
// Discovery is the part of the provider's metadata that is used.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
}
type Identity struct {
Sub string
Email string
Name string
Username string
}
func New(issuer, clientID, clientSecret, redirect string) *Client {
return &Client{
issuer: strings.TrimRight(issuer, "/"), id: clientID, secret: clientSecret, redirect: redirect,
http: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
}
}
// Random returns n random bytes as URL-safe text.
func Random(n int) string {
b := make([]byte, n)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// Challenge is the PKCE S256 challenge for a verifier.
func Challenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func secureEnough(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
return u.Scheme == "https" || (u.Scheme == "http" && config.IsLocal(u))
}
// Discover fetches (and for an hour remembers) the provider's metadata.
func (c *Client) Discover(ctx context.Context) (*Discovery, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.disco != nil && time.Since(c.fetched) < time.Hour {
return c.disco, nil
}
req, _ := http.NewRequestWithContext(ctx, "GET", c.issuer+"/.well-known/openid-configuration", nil)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: %s answered %s (is the issuer address right?)", ErrUnavailable, req.URL, resp.Status)
}
var d Discovery
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil {
return nil, fmt.Errorf("%w: unreadable metadata: %v", ErrUnavailable, err)
}
if strings.TrimRight(d.Issuer, "/") != c.issuer {
return nil, fmt.Errorf("%w: the provider says its issuer is %q, not %q", ErrUnavailable, d.Issuer, c.issuer)
}
if !secureEnough(d.AuthorizationEndpoint) || !secureEnough(d.TokenEndpoint) {
return nil, fmt.Errorf("%w: the provider's endpoints are missing or not https", ErrUnavailable)
}
c.disco, c.fetched = &d, time.Now()
return c.disco, nil
}
// AuthURL is where to send the browser to sign in.
func (c *Client) AuthURL(ctx context.Context, state, nonce, challenge string) (string, error) {
d, err := c.Discover(ctx)
if err != nil {
return "", err
}
u, _ := url.Parse(d.AuthorizationEndpoint)
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", c.id)
q.Set("redirect_uri", c.redirect)
q.Set("scope", "openid profile email")
q.Set("state", state)
q.Set("nonce", nonce)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
u.RawQuery = q.Encode()
return u.String(), nil
}
type audience []string
func (a *audience) UnmarshalJSON(b []byte) error {
var one string
if json.Unmarshal(b, &one) == nil {
*a = audience{one}
return nil
}
var many []string
if err := json.Unmarshal(b, &many); err != nil {
return err
}
*a = many
return nil
}
type claims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud audience `json:"aud"`
Azp string `json:"azp"`
Exp float64 `json:"exp"`
Nonce string `json:"nonce"`
Email string `json:"email"`
Name string `json:"name"`
Username string `json:"preferred_username"`
}
// Exchange trades the code from the redirect for who signed in.
func (c *Client) Exchange(ctx context.Context, code, verifier, nonce string) (Identity, error) {
d, err := c.Discover(ctx)
if err != nil {
return Identity{}, err
}
form := url.Values{
"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {c.redirect},
"client_id": {c.id}, "client_secret": {c.secret}, "code_verifier": {verifier},
}
req, _ := http.NewRequestWithContext(ctx, "POST", d.TokenEndpoint, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return Identity{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer resp.Body.Close()
var tok struct {
IDToken string `json:"id_token"`
Error string `json:"error"`
Desc string `json:"error_description"`
}
json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok)
if resp.StatusCode != http.StatusOK || tok.Error != "" {
if resp.StatusCode >= 500 {
return Identity{}, fmt.Errorf("%w: token endpoint answered %s", ErrUnavailable, resp.Status)
}
return Identity{}, fmt.Errorf("the provider refused the code: %s %s (%s)", tok.Error, tok.Desc, resp.Status)
}
return c.verify(tok.IDToken, nonce, time.Now())
}
func (c *Client) verify(idToken, nonce string, now time.Time) (Identity, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return Identity{}, errors.New("the provider sent no ID token (is the openid scope enabled?)")
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))
if err != nil {
return Identity{}, errors.New("the ID token is not readable")
}
var cl claims
if err := json.Unmarshal(raw, &cl); err != nil {
return Identity{}, errors.New("the ID token is not readable")
}
switch {
case strings.TrimRight(cl.Iss, "/") != c.issuer:
return Identity{}, fmt.Errorf("the ID token is from %q, not %q", cl.Iss, c.issuer)
case !contains(cl.Aud, c.id):
return Identity{}, errors.New("the ID token is for a different application")
case len(cl.Aud) > 1 && cl.Azp != c.id:
return Identity{}, errors.New("the ID token was issued to a different application")
case cl.Exp == 0 || now.Unix() > int64(cl.Exp)+60:
return Identity{}, errors.New("the ID token has expired")
case nonce == "" || subtle.ConstantTimeCompare([]byte(cl.Nonce), []byte(nonce)) != 1:
return Identity{}, errors.New("the ID token does not match this sign-in attempt")
case cl.Sub == "":
return Identity{}, errors.New("the ID token has no subject")
}
return Identity{Sub: cl.Sub, Email: cl.Email, Name: cl.Name, Username: cl.Username}, nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}