aboutsummaryrefslogtreecommitdiff
path: root/auth
diff options
context:
space:
mode:
authorGuillermo Serrahima <dev@vylion.com>2026-04-11 07:40:30 +0200
committerGuillermo Serrahima <dev@vylion.com>2026-04-11 23:39:19 +0200
commit9f0a9a6779f00786e4728ad5c3fad36814a293b0 (patch)
tree4b16d535c5d41ef46980608a88b7581be3c2bfd0 /auth
parentf6d60077b7cfc0d589fa7961e2bb59aea9f993a3 (diff)
downloadllostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.tar.xz
llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.zip
project restructure
Diffstat (limited to 'auth')
-rw-r--r--auth/dpop.go83
-rw-r--r--auth/oauth.go221
2 files changed, 304 insertions, 0 deletions
diff --git a/auth/dpop.go b/auth/dpop.go
new file mode 100644
index 0000000..2a8f6ed
--- /dev/null
+++ b/auth/dpop.go
@@ -0,0 +1,83 @@
+package auth
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/base64"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type DPoPKeyPair struct {
+ PrivateKey *ecdsa.PrivateKey
+ PublicKey *ecdsa.PublicKey
+ JWK map[string]string
+}
+
+func GenerateDPoPKeyPair() (*DPoPKeyPair, error) {
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+
+ jwk := map[string]string{
+ "kty": "EC",
+ "crv": "P-256",
+ "x": base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.X.Bytes()),
+ "y": base64.RawURLEncoding.EncodeToString(privateKey.PublicKey.Y.Bytes()),
+ }
+
+ return &DPoPKeyPair{
+ PrivateKey: privateKey,
+ PublicKey: &privateKey.PublicKey,
+ JWK: jwk,
+ }, nil
+}
+
+func (kp *DPoPKeyPair) GenerateDPoPProof(method, url, nonce string) (string, error) {
+ // generate unique ID for this proof
+ jti, err := GenerateRandomToken()
+ if err != nil {
+ return "", err
+ }
+
+ // create JWT header with JWK
+ header := map[string]interface{}{
+ "typ": "dpop+jwt",
+ "alg": "ES256",
+ "jwk": kp.JWK,
+ }
+
+ // create JWT claims
+ claims := jwt.MapClaims{
+ "jti": jti,
+ "htm": method, // HTTP method (GET, POST, etc)
+ "htu": url, // HTTP URL (the endpoint you're calling)
+ "iat": time.Now().Unix(),
+ }
+
+ // add nonce if provided
+ if nonce != "" {
+ claims["nonce"] = nonce
+ }
+
+ // create token with custom header
+ token := jwt.NewWithClaims(&jwt.SigningMethodECDSA{
+ Name: "ES256",
+ Hash: crypto.SHA256,
+ KeySize: 32,
+ CurveBits: 256,
+ }, claims)
+ token.Header = header
+
+ // sign with private key
+ tokenString, err := token.SignedString(kp.PrivateKey)
+ if err != nil {
+ return "", err
+ }
+
+ return tokenString, nil
+}
diff --git a/auth/oauth.go b/auth/oauth.go
new file mode 100644
index 0000000..a1b0419
--- /dev/null
+++ b/auth/oauth.go
@@ -0,0 +1,221 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type OAuthConfig struct {
+ ClientID string
+ RedirectURI string
+ Scopes []string
+}
+
+type OAuthState struct {
+ State string
+ CodeVerifier string
+ DID string
+ PDS string
+ AccessToken string
+ RefreshToken string
+ ExpiresAt time.Time
+ KeyPair *DPoPKeyPair
+}
+
+type OAuthMetadata struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ Scopes []string `json:"scopes_supported"`
+}
+
+type TokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+func (s *OAuthState) Copy() *OAuthState {
+ return &OAuthState{
+ State: s.State,
+ DID: s.DID,
+ PDS: s.PDS,
+ AccessToken: s.AccessToken,
+ RefreshToken: s.RefreshToken,
+ ExpiresAt: s.ExpiresAt,
+ KeyPair: s.KeyPair,
+ }
+}
+
+func GenerateRandomToken() (string, error) {
+ b := make([]byte, 32)
+ _, err := rand.Read(b)
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func GenerateCodeChallenge(verifier string) string {
+ h := sha256.Sum256([]byte(verifier))
+ return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(h[:])
+}
+
+func FetchOAuthMetadata(pdsURL string) (*OAuthMetadata, error) {
+ // first get the resource metadata to find the auth server
+ resourceURL := fmt.Sprintf("%s/.well-known/oauth-protected-resource", pdsURL)
+ fmt.Printf("Fetching oauth metadata from: %s\n", resourceURL)
+ resp, err := http.Get(resourceURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var resource struct {
+ AuthorizationServers []string `json:"authorization_servers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
+ return nil, err
+ }
+
+ if len(resource.AuthorizationServers) == 0 {
+ return nil, fmt.Errorf("no authorization servers found")
+ }
+
+ // now fetch the actual oauth metadata from the auth server
+ authServer := resource.AuthorizationServers[0]
+ metadataURL := fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServer)
+
+ resp, err = http.Get(metadataURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var metadata OAuthMetadata
+ if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
+ return nil, err
+ }
+
+ return &metadata, nil
+}
+
+func BuildAuthorizationURL(metadata *OAuthMetadata, config *OAuthConfig, state, codeChallenge, handle string) string {
+ params := url.Values{
+ "response_type": {"code"},
+ "client_id": {config.ClientID},
+ "redirect_uri": {config.RedirectURI},
+ "state": {state},
+ "code_challenge": {codeChallenge},
+ "code_challenge_method": {"S256"},
+ "scope": {"atproto"},
+ "login_hint": {handle},
+ }
+
+ return metadata.AuthorizationEndpoint + "?" + params.Encode()
+}
+
+func postWithDPoP(url string, data url.Values, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ dpopProof, err := keypair.GenerateDPoPProof("POST", url, "")
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("DPoP", dpopProof)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 400 {
+ body, _ := io.ReadAll(resp.Body)
+ bodyStr := string(body)
+
+ if strings.Contains(bodyStr, "use_dpop_nonce") {
+ nonce := resp.Header.Get("DPoP-Nonce")
+ if nonce == "" {
+ return nil, fmt.Errorf("server requires nonce but didn't provide one")
+ }
+
+ dpopProof, err = keypair.GenerateDPoPProof("POST", url, nonce)
+ if err != nil {
+ return nil, err
+ }
+
+ retryReq, err := http.NewRequest("POST", url, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ retryReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ retryReq.Header.Set("DPoP", dpopProof)
+
+ resp, err = http.DefaultClient.Do(retryReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ } else {
+ return nil, fmt.Errorf("request failed: 400 - %s", bodyStr)
+ }
+ }
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("request failed: %d - %s", resp.StatusCode, string(body))
+ }
+
+ var tokens TokenResponse
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return nil, err
+ }
+
+ return &tokens, nil
+}
+
+func ExchangeCodeForTokens(pdsURL, code, verifier, callbackURL string, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ metadata, err := FetchOAuthMetadata(pdsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ data := url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {callbackURL},
+ "code_verifier": {verifier},
+ "client_id": {callbackURL},
+ }
+
+ return postWithDPoP(metadata.TokenEndpoint, data, keypair)
+}
+
+func RefreshAccessToken(pdsURL, refreshToken, callbackURL string, keypair *DPoPKeyPair) (*TokenResponse, error) {
+ metadata, err := FetchOAuthMetadata(pdsURL)
+ if err != nil {
+ return nil, err
+ }
+
+ data := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ "client_id": {callbackURL},
+ }
+
+ return postWithDPoP(metadata.TokenEndpoint, data, keypair)
+}