Basking: sign in with Authentik only, pages that nest and link
Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
@@ -0,0 +1,234 @@
|
||||
// Package oidc is the small slice of OpenID Connect Basking 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/basking/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
|
||||
}
|
||||
Reference in new issue
Block a user