235 lines
7.3 KiB
Go
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
|
|
}
|