package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "strings" "time" ) // appClaims is the payload of a desktop-app access token. These tokens are // minted by this server after a successful Authentik login and are independent // of the (short-lived) OIDC tokens, so a friend can paste one into the desktop // app and keep using it until it expires. type appClaims struct { Subject string `json:"sub"` Email string `json:"email"` Expires int64 `json:"exp"` // unix seconds } // mintToken creates a stateless, HMAC-signed token of the form // base64url(payload).base64url(sig). No server-side storage is needed. func mintToken(secret []byte, sub, email string, ttl time.Duration) (string, error) { payload := appClaims{ Subject: sub, Email: email, Expires: time.Now().Add(ttl).Unix(), } raw, err := json.Marshal(payload) if err != nil { return "", err } body := base64.RawURLEncoding.EncodeToString(raw) sig := sign(secret, body) return body + "." + sig, nil } // verifyToken validates the signature and expiry, returning the claims. func verifyToken(secret []byte, token string) (*appClaims, error) { parts := strings.SplitN(strings.TrimSpace(token), ".", 2) if len(parts) != 2 { return nil, errors.New("malformed token") } expected := sign(secret, parts[0]) if !hmac.Equal([]byte(expected), []byte(parts[1])) { return nil, errors.New("bad signature") } raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, errors.New("bad payload encoding") } var claims appClaims if err := json.Unmarshal(raw, &claims); err != nil { return nil, errors.New("bad payload") } if time.Now().Unix() >= claims.Expires { return nil, errors.New("token expired") } return &claims, nil } func sign(secret []byte, body string) string { m := hmac.New(sha256.New, secret) m.Write([]byte(body)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) }