···1616ATPROTO_CLIENT_ID=
1717ATPROTO_METADATA_URL=
1818ATPROTO_CALLBACK_URL=
1919+ATPROTO_CLIENT_SECRET_KEY={goat key generate -t P-256}
2020+ATPROTO_CLIENT_SECRET_KEY_ID={can be whatever usually a timestamp}
19212022# Last.fm
2123LASTFM_API_KEY=
-1
Dockerfile
···2929#Overwrite the main.css with the one from the builder
3030COPY --from=node_builder /app/static/main.css /app/pages/static/main.css
3131 #generate the jwks
3232-RUN go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
3332RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags='-w -s -extldflags "-static"' -o main ./cmd
3433ARG TARGETOS=${TARGETPLATFORM%%/*}
3534ARG TARGETARCH=${TARGETPLATFORM##*/}
-4
Makefile
···2525 --build-file ./lexcfg.json \
2626 ../atproto/lexicons \
2727 ./lexicons/teal
2828-2929-.PHONY: jwtgen
3030-jwtgen:
3131- go run github.com/haileyok/atproto-oauth-golang/cmd/helper generate-jwks
+8-1
README.md
···23232424This is a break down of what each env variable is and what it may look like
25252626+**_breaking piper/v0.0.2 changes env_**
2727+2828+You now have to bring your own private key to run piper. Can do this via goat `goat key generate -t P-256`. You want the one that is labeled under "Secret Key (Multibase Syntax): save this securely (eg, add to password manager)"
2929+3030+- `ATPROTO_CLIENT_SECRET_KEY` - Private key for oauth confidential client. This can be generated via goat `goat key generate -t P-256`
3131+- `ATPROTO_CLIENT_SECRET_KEY_ID` - Key ID for oauth confidential client. This needs to be persistent and unique, can use a timestamp. Here's one for you: `1758199756`
3232+3333+2634- `SERVER_PORT` - The port piper is hosted on
2735- `SERVER_HOST` - The server host. `localhost` is fine here, or `0.0.0.0` for docker
2836- `SERVER_ROOT_URL` - This needs to be the pubically accessible url created in [Setup](#setup). Like `https://piper.teal.fm`
···5361run some make scripts:
54625563```
5656-make jwtgen
57645865make dev-setup
5966```
+16-11
cmd/main.go
···55 "fmt"
66 "log"
77 "net/http"
88- "os"
98 "time"
1091110 "github.com/teal-fm/piper/service/lastfm"
···5756 log.Fatalf("Error initializing database: %v", err)
5857 }
59585959+ sessionManager := session.NewSessionManager(database)
6060+6061 // --- Service Initializations ---
6161- jwksBytes, err := os.ReadFile("./jwks.json")
6262- if err != nil {
6363- // run `make jwtgen`
6464- log.Fatalf("Error reading JWK file: %v", err)
6262+6363+ var newJwkPrivateKey = viper.GetString("atproto.client_secret_key")
6464+ if newJwkPrivateKey == "" {
6565+ fmt.Printf("You now have to set the ATPROTO_CLIENT_SECRET_KEY env var to a private key. This can be done via goat key generate -t P-256")
6666+ return
6567 }
6666- jwks, err := atproto.LoadJwks(jwksBytes)
6767- if err != nil {
6868- log.Fatalf("Error loading JWK: %v", err)
6868+ var clientSecretKeyId = viper.GetString("atproto.client_secret_key_id")
6969+ if clientSecretKeyId == "" {
7070+ fmt.Printf("You also now have to set the ATPROTO_CLIENT_SECRET_KEY_ID env var to a key ID. This needs to be persistent and unique. Here's one for you: %d", time.Now().Unix())
7171+ return
6972 }
7373+7074 atprotoService, err := atproto.NewATprotoAuthService(
7175 database,
7272- jwks,
7676+ sessionManager,
7777+ newJwkPrivateKey,
7378 viper.GetString("atproto.client_id"),
7479 viper.GetString("atproto.callback_url"),
8080+ clientSecretKeyId,
7581 )
7682 if err != nil {
7783 log.Fatalf("Error creating ATproto auth service: %v", err)
···8288 spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService)
8389 lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService)
84908585- sessionManager := session.NewSessionManager(database)
8686- oauthManager := oauth.NewOAuthServiceManager(sessionManager)
9191+ oauthManager := oauth.NewOAuthServiceManager()
87928893 spotifyOAuth := oauth.NewOAuth2Service(
8994 viper.GetString("spotify.client_id"),
···1818 LastFMUsername *string
19192020 // atp info
2121- ATProtoDID *string
2222- ATProtoAccessToken *string
2323- ATProtoRefreshToken *string
2424- ATProtoTokenExpiry *time.Time
2121+ ATProtoDID *string
2222+ //This is meant to only be used by the automated music stamping service. If the user ever does an
2323+ //atproto action from the web ui use the atproto session id for the logged-in session
2424+ MostRecentAtProtoSessionID *string
2525+ //ATProtoAccessToken *string
2626+ //ATProtoRefreshToken *string
2727+ //ATProtoTokenExpiry *time.Time
25282629 CreatedAt time.Time
2730 UpdatedAt time.Time
+104-134
oauth/atproto/atproto.go
···33import (
44 "context"
55 "fmt"
66+77+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
88+ _ "github.com/bluesky-social/indigo/atproto/auth/oauth"
99+ "github.com/bluesky-social/indigo/atproto/client"
1010+ "github.com/bluesky-social/indigo/atproto/crypto"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1212+ "github.com/teal-fm/piper/db"
1313+1414+ "github.com/teal-fm/piper/session"
1515+616 "log"
717 "net/http"
818 "net/url"
99-1010- oauth "github.com/haileyok/atproto-oauth-golang"
1111- "github.com/haileyok/atproto-oauth-golang/helpers"
1212- "github.com/lestrrat-go/jwx/v2/jwk"
1313- "github.com/teal-fm/piper/db"
1414- "github.com/teal-fm/piper/models"
1919+ "os"
2020+ "slices"
1521)
16221723type ATprotoAuthService struct {
1818- client *oauth.Client
1919- jwks jwk.Key
2020- DB *db.DB
2121- clientId string
2222- callbackUrl string
2323- xrpc *oauth.XrpcClient
2424+ clientApp *oauth.ClientApp
2525+ DB *db.DB
2626+ sessionManager *session.SessionManager
2727+ clientId string
2828+ callbackUrl string
2929+ logger *log.Logger
2430}
25312626-func NewATprotoAuthService(db *db.DB, jwks jwk.Key, clientId string, callbackUrl string) (*ATprotoAuthService, error) {
3232+func NewATprotoAuthService(database *db.DB, sessionManager *session.SessionManager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string) (*ATprotoAuthService, error) {
2733 fmt.Println(clientId, callbackUrl)
2828- cli, err := oauth.NewClient(oauth.ClientArgs{
2929- ClientJwk: jwks,
3030- ClientId: clientId,
3131- RedirectUri: callbackUrl,
3232- })
3434+3535+ scopes := []string{"atproto", "repo:fm.teal.alpha.feed.play", "repo:fm.teal.alpha.actor.status"}
3636+3737+ var config oauth.ClientConfig
3838+ config = oauth.NewPublicConfig(clientId, callbackUrl, scopes)
3939+4040+ priv, err := crypto.ParsePrivateMultibase(clientSecretKey)
3341 if err != nil {
3434- return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
4242+ return nil, err
3543 }
4444+ if err := config.SetClientSecret(priv, clientSecretId); err != nil {
4545+ return nil, err
4646+ }
4747+4848+ oauthClient := oauth.NewClientApp(&config, db.NewSqliteATProtoStore(database.DB))
4949+5050+ logger := log.New(os.Stdout, "ATProto oauth: ", log.LstdFlags|log.Lmsgprefix)
5151+3652 svc := &ATprotoAuthService{
3737- client: cli,
3838- jwks: jwks,
3939- callbackUrl: callbackUrl,
4040- DB: db,
4141- clientId: clientId,
5353+ clientApp: oauthClient,
5454+ callbackUrl: callbackUrl,
5555+ DB: database,
5656+ sessionManager: sessionManager,
5757+ clientId: clientId,
5858+ logger: logger,
4259 }
4343- svc.NewXrpcClient()
4460 return svc, nil
4561}
46624747-func (a *ATprotoAuthService) GetATProtoClient() (*oauth.Client, error) {
4848- if a.client != nil {
4949- return a.client, nil
6363+func (a *ATprotoAuthService) GetATProtoClient(accountDID string, sessionID string, ctx context.Context) (*client.APIClient, error) {
6464+ did, err := syntax.ParseDID(accountDID)
6565+ if err != nil {
6666+ return nil, err
5067 }
51685252- if a.client == nil {
5353- cli, err := oauth.NewClient(oauth.ClientArgs{
5454- ClientJwk: a.jwks,
5555- ClientId: a.clientId,
5656- RedirectUri: a.callbackUrl,
5757- })
5858- if err != nil {
5959- return nil, fmt.Errorf("failed to create atproto oauth client: %w", err)
6060- }
6161- a.client = cli
6969+ oauthSess, err := a.clientApp.ResumeSession(ctx, did, sessionID)
7070+ if err != nil {
7171+ return nil, err
6272 }
63736464- return a.client, nil
6565-}
7474+ return oauthSess.APIClient(), nil
66756767-func LoadJwks(jwksBytes []byte) (jwk.Key, error) {
6868- key, err := helpers.ParseJWKFromBytes(jwksBytes)
6969- if err != nil {
7070- return nil, fmt.Errorf("failed to parse JWK from bytes: %w", err)
7171- }
7272- return key, nil
7376}
74777578func (a *ATprotoAuthService) HandleLogin(w http.ResponseWriter, r *http.Request) {
7679 handle := r.URL.Query().Get("handle")
7780 if handle == "" {
7878- log.Printf("ATProto Login Error: handle is required")
8181+ a.logger.Printf("ATProto Login Error: handle is required")
7982 http.Error(w, "handle query parameter is required", http.StatusBadRequest)
8083 return
8184 }
8282-8383- authUrl, err := a.getLoginUrlAndSaveState(r.Context(), handle)
8585+ ctx := r.Context()
8686+ redirectURL, err := a.clientApp.StartAuthFlow(ctx, handle)
8787+ if err != nil {
8888+ http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
8989+ }
9090+ authUrl, err := url.Parse(redirectURL)
8491 if err != nil {
8585- log.Printf("ATProto Login Error: Failed to get login URL for handle %s: %v", handle, err)
8692 http.Error(w, fmt.Sprintf("Error initiating login: %v", err), http.StatusInternalServerError)
8787- return
8893 }
89949090- log.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
9595+ a.logger.Printf("ATProto Login: Redirecting user %s to %s", handle, authUrl.String())
9196 http.Redirect(w, r, authUrl.String(), http.StatusFound)
9297}
93989494-func (a *ATprotoAuthService) getLoginUrlAndSaveState(ctx context.Context, handle string) (*url.URL, error) {
9595- scope := "atproto transition:generic"
9696- // resolve
9797- ui, err := a.getUserInformation(ctx, handle)
9898- if err != nil {
9999- return nil, fmt.Errorf("failed to get user information for %s: %w", handle, err)
100100- }
101101-102102- fmt.Println("user info: ", ui.AuthServer, ui.AuthService)
103103-104104- // create a dpop jwk for this session
105105- k, err := helpers.GenerateKey(nil) // Generate ephemeral DPoP key for this flow
106106- if err != nil {
107107- return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
108108- }
9999+func (a *ATprotoAuthService) HandleLogout(w http.ResponseWriter, r *http.Request) {
100100+ cookie, err := r.Cookie("session")
109101110110- // Send PAR auth req
111111- parResp, err := a.client.SendParAuthRequest(ctx, ui.AuthServer, ui.AuthMeta, ui.Handle, scope, k)
112112- if err != nil {
113113- return nil, fmt.Errorf("failed PAR request to %s: %w", ui.AuthServer, err)
114114- }
102102+ if err == nil {
103103+ session, exists := a.sessionManager.GetSession(cookie.Value)
104104+ if !exists {
105105+ http.Redirect(w, r, "/", http.StatusSeeOther)
106106+ return
107107+ }
115108116116- // Save state
117117- data := &models.ATprotoAuthData{
118118- State: parResp.State,
119119- DID: ui.DID,
120120- PDSUrl: ui.AuthService,
121121- AuthServerIssuer: ui.AuthMeta.Issuer,
122122- PKCEVerifier: parResp.PkceVerifier,
123123- DPoPAuthServerNonce: parResp.DpopAuthserverNonce,
124124- DPoPPrivateJWK: k,
125125- }
109109+ dbUser, err := a.DB.GetUserByID(session.UserID)
110110+ if err != nil {
111111+ http.Redirect(w, r, "/", http.StatusSeeOther)
112112+ return
113113+ }
114114+ did, err := syntax.ParseDID(*dbUser.ATProtoDID)
126115127127- // print data
128128- fmt.Println(data)
116116+ if err != nil {
117117+ a.logger.Printf("Should not happen: %s", err)
118118+ a.sessionManager.ClearSessionCookie(w)
119119+ http.Redirect(w, r, "/", http.StatusSeeOther)
120120+ }
129121130130- err = a.DB.SaveATprotoAuthData(data)
131131- if err != nil {
132132- return nil, fmt.Errorf("failed to save ATProto auth data for state %s: %w", parResp.State, err)
122122+ ctx := r.Context()
123123+ err = a.clientApp.Logout(ctx, did, session.ATProtoSessionID)
124124+ if err != nil {
125125+ a.logger.Printf("Error logging the user: %s out: %s", did, err)
126126+ }
127127+ a.sessionManager.DeleteSession(cookie.Value)
133128 }
134129135135- // Construct authorization URL using the request_uri from PAR response
136136- authEndpointURL, err := url.Parse(ui.AuthMeta.AuthorizationEndpoint)
137137- if err != nil {
138138- return nil, fmt.Errorf("invalid authorization endpoint URL %s: %w", ui.AuthMeta.AuthorizationEndpoint, err)
139139- }
140140- q := authEndpointURL.Query()
141141- q.Set("client_id", a.clientId)
142142- q.Set("request_uri", parResp.RequestUri)
143143- q.Set("state", parResp.State)
144144- authEndpointURL.RawQuery = q.Encode()
130130+ a.sessionManager.ClearSessionCookie(w)
145131146146- return authEndpointURL, nil
132132+ http.Redirect(w, r, "/", http.StatusSeeOther)
147133}
148134149135func (a *ATprotoAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
150150- state := r.URL.Query().Get("state")
151151- code := r.URL.Query().Get("code")
152152- issuer := r.URL.Query().Get("iss") // Issuer (auth base URL) is needed for token request
153153-154154- if state == "" || code == "" || issuer == "" {
155155- errMsg := r.URL.Query().Get("error")
156156- errDesc := r.URL.Query().Get("error_description")
157157- log.Printf("ATProto Callback Error: Missing parameters. State: '%s', Code: '%s', Issuer: '%s'. Error: '%s', Description: '%s'", state, code, issuer, errMsg, errDesc)
158158- http.Error(w, fmt.Sprintf("Authorization callback failed: %s (%s). Missing state, code, or issuer.", errMsg, errDesc), http.StatusBadRequest)
159159- return 0, fmt.Errorf("missing state, code, or issuer")
160160- }
136136+ ctx := r.Context()
161137162162- // Retrieve saved data using state
163163- data, err := a.DB.GetATprotoAuthData(state)
138138+ sessData, err := a.clientApp.ProcessCallback(ctx, r.URL.Query())
164139 if err != nil {
165165- log.Printf("ATProto Callback Error: Failed to retrieve auth data for state '%s': %v", state, err)
166166- http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
167167- return 0, fmt.Errorf("invalid or expired state")
140140+ errMsg := fmt.Errorf("processing OAuth callback: %w", err)
141141+ http.Error(w, errMsg.Error(), http.StatusBadRequest)
142142+ return 0, errMsg
168143 }
169144170170- // Clean up the temporary auth data now that we've retrieved it
171171- // defer a.DB.DeleteATprotoAuthData(state) // Consider adding deletion logic
172172- // if issuers don't match, return an error
173173- if data.AuthServerIssuer != issuer {
174174- log.Printf("ATProto Callback Error: Issuer mismatch for state '%s', expected '%s', got '%s'", state, data.AuthServerIssuer, issuer)
175175- http.Error(w, "Invalid or expired state.", http.StatusBadRequest)
176176- return 0, fmt.Errorf("issuer mismatch")
145145+ // It's in the example repo and leaving for some debugging cause i've seen different scopes cause issues before
146146+ // so may be some nice debugging info to have
147147+ if !slices.Equal(sessData.Scopes, a.clientApp.Config.Scopes) {
148148+ a.logger.Printf("session auth scopes did not match those requested")
177149 }
178150179179- resp, err := a.client.InitialTokenRequest(r.Context(), code, issuer, data.PKCEVerifier, data.DPoPAuthServerNonce, data.DPoPPrivateJWK)
151151+ user, err := a.DB.FindOrCreateUserByDID(sessData.AccountDID.String())
180152 if err != nil {
181181- log.Printf("ATProto Callback Error: Failed initial token request for state '%s', issuer '%s': %v", state, issuer, err)
182182- http.Error(w, fmt.Sprintf("Error exchanging code for token: %v", err), http.StatusInternalServerError)
183183- return 0, fmt.Errorf("failed initial token request")
184184- }
185185-186186- userID, err := a.DB.FindOrCreateUserByDID(data.DID)
187187- if err != nil {
188188- log.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", data.DID, err)
153153+ a.logger.Printf("ATProto Callback Error: Failed to find or create user for DID %s: %v", sessData.AccountDID.String(), err)
189154 http.Error(w, "Failed to process user information.", http.StatusInternalServerError)
190155 return 0, fmt.Errorf("failed to find or create user")
191156 }
192157193193- err = a.DB.SaveATprotoSession(resp, data.AuthServerIssuer, data.DPoPPrivateJWK, data.PDSUrl)
158158+ //This is piper's session for manging piper, not atproto sessions
159159+ createdSession := a.sessionManager.CreateSession(user.ID, sessData.SessionID)
160160+ a.sessionManager.SetSessionCookie(w, createdSession)
161161+ a.logger.Printf("Created session for user %d via service atproto", user.ATProtoDID)
162162+163163+ err = a.DB.SetLatestATProtoSessionId(sessData.AccountDID.String(), sessData.SessionID)
194164 if err != nil {
195195- log.Printf("ATProto Callback Error: Failed to save ATProto tokens for user %d (DID %s): %v", userID.ID, data.DID, err)
165165+ a.logger.Printf("Failed to set latest atproto session id for user %d: %v", user.ID, err)
196166 }
197167198198- log.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", userID.ID, data.DID)
199199- return userID.ID, nil
168168+ a.logger.Printf("ATProto Callback Success: User %d (DID: %s) authenticated.", user.ID, user.ATProtoDID)
169169+ return user.ID, nil
200170}
···8686 http.Redirect(w, r, authURL, http.StatusSeeOther)
8787}
88888989+func (o *OAuth2Service) HandleLogout(w http.ResponseWriter, r *http.Request) {
9090+ //TODO not implemented yet. not sure what the api call is for this package
9191+ http.Redirect(w, r, "/", http.StatusSeeOther)
9292+}
9393+8994func (o *OAuth2Service) HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error) {
9095 state := r.URL.Query().Get("state")
9196 if state != o.state {
···1010 // handles the callback for the provider. is responsible for inserting
1111 // sessions in the db
1212 HandleCallback(w http.ResponseWriter, r *http.Request) (int64, error)
1313+1414+ HandleLogout(w http.ResponseWriter, r *http.Request)
1315}
14161517// optional but recommended