// Package oidc is the small slice of OpenID Connect Nurse 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/nurse/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 }