feat: use redis to store oauth sessions and switch to new indigo oauth library #3

merged
opened by brookjeynes.dev targeting master from bj/2025-10-08/feat/indigo-oauth
+4 -3
go.mod
···
toolchain go1.24.4
-
replace github.com/bluesky-social/indigo => github.com/oppiliappan/indigo v0.0.0-20250728090836-5f170569da93
-
require (
github.com/a-h/templ v0.3.898
-
github.com/bluesky-social/indigo v0.0.0-20250728163042-01ae6633b28c
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e
github.com/carlmjohnson/versioninfo v0.22.5
github.com/go-chi/chi/v5 v5.2.1
···
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/mattn/go-sqlite3 v1.14.22
github.com/posthog/posthog-go v1.5.12
+
github.com/redis/go-redis/v9 v9.14.0
github.com/sethvargo/go-envconfig v1.3.0
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
golang.org/x/net v0.42.0
···
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
···
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
+
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
+13 -2
go.sum
···
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo=
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
+
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
···
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
···
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
-
github.com/oppiliappan/indigo v0.0.0-20250728090836-5f170569da93 h1:7HH9daZ4xfUJUlHlH82i1xHyKSqKj/OWO+3aQLZbQWM=
-
github.com/oppiliappan/indigo v0.0.0-20250728090836-5f170569da93/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9/go.mod h1:x3N5drFsm2uilKKuuYo6LdyD8vZAW55sH/9w+pbo1sw=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
···
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
+
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
+23
internal/server/config/config.go
···
import (
"context"
+
"fmt"
+
"net/url"
"github.com/sethvargo/go-envconfig"
)
···
ApiKey string `env:"API_KEY"`
}
+
type RedisConfig struct {
+
Addr string `env:"ADDR, default=localhost:6379"`
+
Password string `env:"PASS"`
+
DB int `env:"DB, default=0"`
+
}
+
+
func (cfg RedisConfig) ToURL() string {
+
u := &url.URL{
+
Scheme: "redis",
+
Host: cfg.Addr,
+
Path: fmt.Sprintf("/%d", cfg.DB),
+
}
+
+
if cfg.Password != "" {
+
u.User = url.UserPassword("default", cfg.Password)
+
}
+
+
return u.String()
+
}
+
type Config struct {
Core CoreConfig `env:",prefix=YOTEN_"`
Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"`
OAuth OAuthConfig `env:",prefix=YOTEN_OAUTH_"`
Posthog PosthogConfig `env:",prefix=YOTEN_POSTHOG_"`
Google GoogleConfig `env:",prefix=YOTEN_GOOGLE_"`
+
Redis RedisConfig `env:",prefix=YOTEN_REDIS_"`
}
func LoadConfig(ctx context.Context) (*Config, error) {
+9 -1
internal/server/views/login.templ
···
</div>
<div class="flex flex-col gap-2">
<label for="handle">Handle or DID</label>
-
<input type="text" id="handle" name="handle" placeholder="username.bsky.social" class="input" autocomplete="username"/>
+
<input
+
type="text"
+
id="handle"
+
name="handle"
+
placeholder="username.bsky.social"
+
class="input"
+
autocomplete="username"
+
/>
<p class="text-xs text-text-muted">Enter your Bluesky handle (e.g., alice.bsky.social) or full DID</p>
</div>
+
<input type="hidden" name="return_url" value={ params.ReturnUrl }/>
<button class="btn btn-primary" type="submit" id="login-button">
<span>Log in with AT Protocol</span>
<i class="w-4 h-4" data-lucide="arrow-right"></i>
+2 -1
internal/server/views/views.go
···
type LoginPageParams struct {
// The current logged in user.
-
User *types.User
+
User *types.User
+
ReturnUrl string
}
type ProfilePageParams struct {
-55
internal/atproto/xrpc.go
···
-
package atproto
-
-
import (
-
"context"
-
-
"github.com/bluesky-social/indigo/api/atproto"
-
"github.com/bluesky-social/indigo/xrpc"
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
-
)
-
-
type Client struct {
-
*oauth.XrpcClient
-
authArgs *oauth.XrpcAuthedRequestArgs
-
}
-
-
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
-
return &Client{
-
XrpcClient: client,
-
authArgs: authArgs,
-
}
-
}
-
-
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
-
var out atproto.RepoPutRecord_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
-
var out atproto.RepoGetRecord_Output
-
-
params := map[string]any{
-
"cid": cid,
-
"collection": collection,
-
"repo": repo,
-
"rkey": rkey,
-
}
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
-
var out atproto.RepoDeleteRecord_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
+14
internal/cache/cache.go
···
+
package cache
+
+
import "github.com/redis/go-redis/v9"
+
+
type Cache struct {
+
*redis.Client
+
}
+
+
func New(addr string) *Cache {
+
rdb := redis.NewClient(&redis.Options{
+
Addr: addr,
+
})
+
return &Cache{rdb}
+
}
+172
internal/cache/session/store.go
···
+
package session
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"time"
+
+
"yoten.app/internal/cache"
+
)
+
+
type OAuthSession struct {
+
Handle string
+
Did string
+
PdsUrl string
+
AccessJwt string
+
RefreshJwt string
+
AuthServerIss string
+
DpopPdsNonce string
+
DpopAuthserverNonce string
+
DpopPrivateJwk string
+
Expiry string
+
}
+
+
type OAuthRequest struct {
+
AuthserverIss string
+
Handle string
+
State string
+
Did string
+
PdsUrl string
+
PkceVerifier string
+
DpopAuthserverNonce string
+
DpopPrivateJwk string
+
ReturnUrl string
+
}
+
+
type SessionStore struct {
+
cache *cache.Cache
+
}
+
+
const (
+
stateKey = "oauthstate:%s"
+
requestKey = "oauthrequest:%s"
+
sessionKey = "oauthsession:%s"
+
)
+
+
func New(cache *cache.Cache) *SessionStore {
+
return &SessionStore{cache: cache}
+
}
+
+
func (s *SessionStore) SaveSession(ctx context.Context, session OAuthSession) error {
+
key := fmt.Sprintf(sessionKey, session.Did)
+
data, err := json.Marshal(session)
+
if err != nil {
+
return err
+
}
+
+
// set with ttl (7 days)
+
ttl := 7 * 24 * time.Hour
+
+
return s.cache.Set(ctx, key, data, ttl).Err()
+
}
+
+
// SaveRequest stores the OAuth request to be later fetched in the callback. Since
+
// the fetching happens by comparing the state we get in the callback params, we
+
// store an additional state->did mapping which then lets us fetch the whole OAuth request.
+
func (s *SessionStore) SaveRequest(ctx context.Context, request OAuthRequest) error {
+
key := fmt.Sprintf(requestKey, request.Did)
+
data, err := json.Marshal(request)
+
if err != nil {
+
return err
+
}
+
+
// oauth flow must complete within 30 minutes
+
err = s.cache.Set(ctx, key, data, 30*time.Minute).Err()
+
if err != nil {
+
return fmt.Errorf("error saving request: %w", err)
+
}
+
+
stateKey := fmt.Sprintf(stateKey, request.State)
+
err = s.cache.Set(ctx, stateKey, request.Did, 30*time.Minute).Err()
+
if err != nil {
+
return fmt.Errorf("error saving state->did mapping: %w", err)
+
}
+
+
return nil
+
}
+
+
func (s *SessionStore) GetSession(ctx context.Context, did string) (*OAuthSession, error) {
+
key := fmt.Sprintf(sessionKey, did)
+
val, err := s.cache.Get(ctx, key).Result()
+
if err != nil {
+
return nil, err
+
}
+
+
var session OAuthSession
+
err = json.Unmarshal([]byte(val), &session)
+
if err != nil {
+
return nil, err
+
}
+
return &session, nil
+
}
+
+
func (s *SessionStore) GetRequestByState(ctx context.Context, state string) (*OAuthRequest, error) {
+
didKey, err := s.getRequestKeyFromState(ctx, state)
+
if err != nil {
+
return nil, err
+
}
+
+
val, err := s.cache.Get(ctx, didKey).Result()
+
if err != nil {
+
return nil, err
+
}
+
+
var request OAuthRequest
+
err = json.Unmarshal([]byte(val), &request)
+
if err != nil {
+
return nil, err
+
}
+
+
return &request, nil
+
}
+
+
func (s *SessionStore) DeleteSession(ctx context.Context, did string) error {
+
key := fmt.Sprintf(sessionKey, did)
+
return s.cache.Del(ctx, key).Err()
+
}
+
+
func (s *SessionStore) DeleteRequestByState(ctx context.Context, state string) error {
+
didKey, err := s.getRequestKeyFromState(ctx, state)
+
if err != nil {
+
return err
+
}
+
+
err = s.cache.Del(ctx, fmt.Sprintf(stateKey, state)).Err()
+
if err != nil {
+
return err
+
}
+
+
return s.cache.Del(ctx, didKey).Err()
+
}
+
+
func (s *SessionStore) RefreshSession(ctx context.Context, did, access, refresh, expiry string) error {
+
session, err := s.GetSession(ctx, did)
+
if err != nil {
+
return err
+
}
+
session.AccessJwt = access
+
session.RefreshJwt = refresh
+
session.Expiry = expiry
+
return s.SaveSession(ctx, *session)
+
}
+
+
func (s *SessionStore) UpdateNonce(ctx context.Context, did, nonce string) error {
+
session, err := s.GetSession(ctx, did)
+
if err != nil {
+
return err
+
}
+
session.DpopAuthserverNonce = nonce
+
return s.SaveSession(ctx, *session)
+
}
+
+
func (s *SessionStore) getRequestKeyFromState(ctx context.Context, state string) (string, error) {
+
key := fmt.Sprintf(stateKey, state)
+
did, err := s.cache.Get(ctx, key).Result()
+
if err != nil {
+
return "", err
+
}
+
+
didKey := fmt.Sprintf(requestKey, did)
+
return didKey, nil
+
}
+18 -10
internal/db/db.go
···
"context"
"database/sql"
"fmt"
+
"strings"
_ "github.com/mattn/go-sqlite3"
)
···
}
func Make(dbPath string) (*DB, error) {
-
db, err := sql.Open("sqlite3", dbPath)
+
opts := []string{
+
"_foreign_keys=1",
+
"_journal_mode=WAL",
+
"_synchronous=NORMAL",
+
"_auto_vacuum=incremental",
+
}
+
+
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
if err != nil {
return nil, fmt.Errorf("failed to open db: %w", err)
}
-
_, err = db.Exec(`
-
pragma journal_mode = WAL;
-
pragma synchronous = normal;
-
pragma foreign_keys = on;
-
pragma temp_store = memory;
-
pragma mmap_size = 30000000000;
-
pragma page_size = 32768;
-
pragma auto_vacuum = incremental;
-
pragma busy_timeout = 5000;
+
ctx := context.Background()
+
+
conn, err := db.Conn(ctx)
+
if err != nil {
+
return nil, err
+
}
+
defer conn.Close()
+
+
_, err = conn.ExecContext(ctx, `
create table if not exists oauth_requests (
id integer primary key autoincrement,
auth_server_iss text not null,
+7 -7
internal/server/handlers/activity.go
···
SortedCategories: h.ComputedData.SortedCategories,
}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
categoriesString = append(categoriesString, c.Name)
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.ActivityDefNSID,
Repo: user.Did,
Rkey: newActivity.Rkey,
···
htmx.HxRedirect(w, "/login")
return
}
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxError(w, http.StatusUnauthorized, "Failed to delete activity, try again later.")
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.ActivityDefNSID,
Repo: user.Did,
Rkey: activity.Rkey,
···
SortedCategories: h.ComputedData.SortedCategories,
}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
return
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.ActivityDefNSID, user.Did, updatedActivity.Rkey)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.ActivityDefNSID, user.Did, updatedActivity.Rkey)
var cid *string
if ex != nil {
cid = ex.Cid
···
categoriesString = append(categoriesString, c.Name)
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.ActivityDefNSID,
Repo: user.Did,
Rkey: updatedActivity.Rkey,
+7 -7
internal/server/handlers/comment.go
···
)
func (h *Handler) HandleNewComment(w http.ResponseWriter, r *http.Request) {
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
CreatedAt: time.Now(),
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedCommentNSID,
Repo: newComment.Did,
Rkey: newComment.Rkey,
···
htmx.HxRedirect(w, "/login")
return
}
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.FeedCommentNSID,
Repo: user.Did,
Rkey: comment.Rkey,
···
case http.MethodGet:
partials.EditComment(partials.EditCommentProps{Comment: comment}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
}
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.FeedCommentNSID, user.Did, updatedComment.Rkey)
var cid *string
if ex != nil {
cid = ex.Cid
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedCommentNSID,
Repo: updatedComment.Did,
Rkey: updatedComment.Rkey,
+3 -3
internal/server/handlers/follow.go
···
)
func (h *Handler) HandleFollow(w http.ResponseWriter, r *http.Request) {
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
rkey := atproto.TID()
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.GraphFollowNSID,
Repo: user.Did,
Rkey: rkey,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.GraphFollowNSID,
Repo: user.Did,
Rkey: follow.Rkey,
+112
internal/server/handlers/login.go
···
+
package handlers
+
+
import (
+
"fmt"
+
"log"
+
"net/http"
+
"strings"
+
+
"github.com/posthog/posthog-go"
+
+
"yoten.app/internal/clients/bsky"
+
ph "yoten.app/internal/clients/posthog"
+
"yoten.app/internal/server/htmx"
+
"yoten.app/internal/server/views"
+
"yoten.app/internal/types"
+
)
+
+
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
var user *types.User
+
oauth := h.Oauth.GetUser(r)
+
if oauth != nil {
+
bskyProfile, err := bsky.GetBskyProfile(oauth.Did)
+
if err != nil {
+
log.Println("failed to get bsky profile:", err)
+
}
+
user = &types.User{
+
OauthUser: *oauth,
+
BskyProfile: bskyProfile,
+
}
+
}
+
+
returnURL := r.URL.Query().Get("return_url")
+
views.LoginPage(views.LoginPageParams{
+
User: user,
+
ReturnUrl: returnURL,
+
}).Render(r.Context(), w)
+
case http.MethodPost:
+
handle := r.FormValue("handle")
+
+
// When users copy their handle from bsky.app, it tends to have these
+
// characters around it:
+
//
+
// @nelind.dk:
+
// \u202a ensures that the handle is always rendered left to right and
+
// \u202c reverts that so the rest of the page renders however it should
+
handle = strings.TrimPrefix(handle, "\u202a")
+
handle = strings.TrimSuffix(handle, "\u202c")
+
+
// `@` is harmless
+
handle = strings.TrimPrefix(handle, "@")
+
+
// Basic handle validation
+
if !strings.Contains(handle, ".") {
+
log.Println("invalid handle format:", handle)
+
htmx.HxError(w, http.StatusBadGateway, fmt.Sprintf("'%s' is an invalid handle. Did you mean %s.bsky.social?", handle, handle))
+
return
+
}
+
+
if !h.Config.Core.Dev {
+
err := h.Posthog.Enqueue(posthog.Capture{
+
DistinctId: handle,
+
Event: ph.UserSignInInitiatedEvent,
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
redirectURL, err := h.Oauth.ClientApp.StartAuthFlow(r.Context(), handle)
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
if !h.Config.Core.Dev {
+
err := h.Posthog.Enqueue(posthog.Capture{
+
DistinctId: handle,
+
Event: ph.UserSignInSuccessEvent,
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
htmx.HxRedirect(w, redirectURL)
+
}
+
}
+
+
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
+
did := h.Oauth.GetDid(r)
+
+
err := h.Oauth.DeleteSession(w, r)
+
if err != nil {
+
log.Println("failed to logout", "err", err)
+
} else {
+
log.Println("logged out successfully")
+
}
+
+
if !h.Config.Core.Dev && did != "" {
+
err := h.Posthog.Enqueue(posthog.Capture{
+
DistinctId: did,
+
Event: ph.UserLoggedOutEvent,
+
})
+
if err != nil {
+
log.Println("failed to enqueue posthog event:", err)
+
}
+
}
+
+
htmx.HxRedirect(w, "/login")
+
}
+4 -4
internal/server/handlers/pending-ops.go
···
}
func ApplyPendingChanges[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, items []T, createKey, updateKey, deleteKey string) ([]T, error) {
-
yotenSession, err := h.Oauth.Store.Get(r, "yoten-session")
+
yotenSession, err := h.Oauth.SessionStore.Get(r, "yoten-session")
if err != nil {
return items, err
}
···
}
func SavePendingCreate[T any](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error {
-
yotenSession, err := h.Oauth.Store.Get(r, "yoten-session")
+
yotenSession, err := h.Oauth.SessionStore.Get(r, "yoten-session")
if err != nil {
return fmt.Errorf("failed to get yoten-session for pending create: %w", err)
}
···
}
func SavePendingUpdate[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error {
-
yotenSession, err := h.Oauth.Store.Get(r, "yoten-session")
+
yotenSession, err := h.Oauth.SessionStore.Get(r, "yoten-session")
if err != nil {
return fmt.Errorf("failed to get yoten-session for pending update: %w", err)
}
···
}
func SavePendingDelete[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error {
-
yotenSession, err := h.Oauth.Store.Get(r, "yoten-session")
+
yotenSession, err := h.Oauth.SessionStore.Get(r, "yoten-session")
if err != nil {
return fmt.Errorf("failed to get yoten-session for pending delete: %w", err)
}
+5 -5
internal/server/handlers/profile.go
···
InitialSelectedLanguages: profileLanguageCodes,
}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
updatedProfile.Level = profile.Level
updatedProfile.Xp = profile.Xp
if updatedProfile.DisplayName == "" {
-
updatedProfile.DisplayName = user.Handle
+
updatedProfile.DisplayName = user.BskyProfile.Handle
}
if err := db.ValidateProfile(updatedProfile); err != nil {
···
return
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.ActorProfileNSID, user.Did, "self")
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.ActorProfileNSID, user.Did, "self")
var cid *string
if ex != nil {
cid = ex.Cid
···
languagesStr = append(languagesStr, string(lc.Code))
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.ActorProfileNSID,
Repo: user.Did,
Rkey: "self",
···
Set("language_count", len(updatedProfile.Languages)).
Set("$set_once", posthog.NewProperties().
Set("initial_did", user.Did).
-
Set("initial_handle", user.Handle).
+
Set("initial_handle", user.BskyProfile.Handle).
Set("created_at", updatedProfile.CreatedAt.Format(time.RFC3339)),
)
+3 -3
internal/server/handlers/reaction.go
···
)
func (h *Handler) HandleReaction(w http.ResponseWriter, r *http.Request) {
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
createdAt := time.Now().Format(time.RFC3339)
rkey := atproto.TID()
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedReactionNSID,
Repo: user.Did,
Rkey: rkey,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.FeedReactionNSID,
Repo: user.Did,
Rkey: reactionEvent.Rkey,
+7 -7
internal/server/handlers/resource.go
···
SortedResourceTypes: h.ComputedData.SortedResourceTypes,
}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
feedResource.Link = newResource.Link
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedResourceNSID,
Repo: user.Did,
Rkey: newResource.Rkey,
···
htmx.HxRedirect(w, "/login")
return
}
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxError(w, http.StatusUnauthorized, "Failed to delete resource, try again later.")
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.FeedResourceNSID,
Repo: user.Did,
Rkey: resource.Rkey,
···
SortedResourceTypes: h.ComputedData.SortedResourceTypes,
}).Render(r.Context(), w)
case http.MethodPost:
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
feedResource.Link = updatedResource.Link
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedResourceNSID, user.Did, resource.Rkey)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.FeedResourceNSID, user.Did, resource.Rkey)
var cid *string
if ex != nil {
cid = ex.Cid
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedResourceNSID,
Repo: user.Did,
Rkey: updatedResource.Rkey,
+9 -12
internal/server/handlers/router.go
···
"strings"
"github.com/go-chi/chi/v5"
-
"github.com/gorilla/sessions"
"yoten.app/internal/server"
"yoten.app/internal/server/middleware"
-
oauthhandler "yoten.app/internal/server/oauth/handler"
"yoten.app/internal/server/views"
)
···
func (h *Handler) StandardRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.LoadUnreadNotificationCount(h.Oauth))
+
r.Use(mw.LoadUnreadNotificationCount())
-
r.Mount("/", h.OAuthRouter())
r.Handle("/static/*", h.HandleStatic())
+
r.Get("/", h.HandleIndexPage)
r.Get("/feed", h.HandleStudySessionFeed)
+
r.Get("/login", h.Login)
+
r.Post("/login", h.Login)
+
r.Post("/logout", h.Logout)
+
r.Route("/friends", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(h.Oauth))
r.Get("/", h.HandleFriendsPage)
···
})
})
+
r.Mount("/", h.Oauth.Router())
+
return r
}
func (h *Handler) UserRouter(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.StripLeadingAt)
-
r.Use(middleware.LoadUnreadNotificationCount(h.Oauth))
+
r.Use(mw.LoadUnreadNotificationCount())
r.Group(func(r chi.Router) {
r.Use(mw.ResolveIdent())
···
return r
}
-
-
func (h *Handler) OAuthRouter() http.Handler {
-
store := sessions.NewCookieStore([]byte(h.Config.Core.CookieSecret))
-
oauth := oauthhandler.New(h.Config, h.Db, store, h.Oauth, h.Posthog)
-
return oauth.Router()
-
}
+7 -7
internal/server/handlers/study-session.go
···
}
func (h *Handler) HandleEditStudySessionPage(w http.ResponseWriter, r *http.Request) {
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
updatedStudySessionRecord.PredefinedActivityName = &updatedStudySession.Activity.Name
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", yoten.FeedSessionNSID, user.Did, updatedStudySession.Rkey)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.FeedSessionNSID, user.Did, updatedStudySession.Rkey)
var cid *string
if ex != nil {
cid = ex.Cid
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedSessionNSID,
Repo: updatedStudySession.Did,
Rkey: updatedStudySession.Rkey,
···
return
}
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxRedirect(w, "/login")
···
newStudySessionRecord.PredefinedActivityName = &newStudySession.Activity.Name
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: yoten.FeedSessionNSID,
Repo: newStudySession.Did,
Rkey: newStudySession.Rkey,
···
return
}
-
client, err := h.Oauth.AuthorizedClient(r, w)
+
client, err := h.Oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client:", err)
htmx.HxError(w, http.StatusUnauthorized, "Failed to delete study session, try again later.")
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: yoten.FeedSessionNSID,
Repo: user.Did,
Rkey: rkey,
+5 -1
internal/server/oauth/consts.go
···
package oauth
const (
-
SessionName = "yoten-oauth-session"
+
SessionName = "yoten-oauth-session-v2"
SessionHandle = "handle"
SessionDid = "did"
+
SessionId = "id"
SessionPds = "pds"
SessionAccessJwt = "accessJwt"
SessionRefreshJwt = "refreshJwt"
SessionExpiry = "expiry"
SessionAuthenticated = "authenticated"
+
+
SessionDpopPrivateJwk = "dpopPrivateJwk"
+
SessionDpopAuthServerNonce = "dpopAuthServerNonce"
)
+78
internal/server/oauth/handler.go
···
+
package oauth
+
+
import (
+
"encoding/json"
+
"log"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
func (o *OAuth) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
+
r.Get("/oauth/jwks.json", o.jwks)
+
r.Get("/oauth/callback", o.callback)
+
+
return r
+
}
+
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
+
doc := o.ClientApp.Config.ClientMetadata()
+
doc.JWKSURI = &o.JwksUri
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
+
k, err := jwk.ParseKey([]byte(jwks))
+
if err != nil {
+
return nil, err
+
}
+
pubKey, err := k.PublicKey()
+
if err != nil {
+
return nil, err
+
}
+
return pubKey, nil
+
}
+
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
+
jwks := o.Config.OAuth.Jwks
+
pubKey, err := pubKeyFromJwk(jwks)
+
if err != nil {
+
log.Printf("failed to parse public key: %v", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
response := map[string]any{
+
"keys": []jwk.Key{pubKey},
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
+
ctx := r.Context()
+
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
if err := o.SaveSession(w, r, sessData); err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
http.Redirect(w, r, "/", http.StatusFound)
+
}
-415
internal/server/oauth/handler/handler.go
···
-
package handler
-
-
import (
-
"encoding/json"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
"time"
-
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"github.com/go-chi/chi/v5"
-
"github.com/gorilla/sessions"
-
"github.com/posthog/posthog-go"
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
-
-
"yoten.app/api/yoten"
-
"yoten.app/internal/atproto"
-
"yoten.app/internal/clients/bsky"
-
ph "yoten.app/internal/clients/posthog"
-
"yoten.app/internal/db"
-
"yoten.app/internal/server/config"
-
"yoten.app/internal/server/htmx"
-
"yoten.app/internal/server/middleware"
-
"yoten.app/internal/server/oauth"
-
"yoten.app/internal/server/oauth/client"
-
"yoten.app/internal/server/views"
-
"yoten.app/internal/types"
-
)
-
-
const (
-
oauthScope = "atproto transition:generic"
-
)
-
-
type OAuthHandler struct {
-
config *config.Config
-
db *db.DB
-
store *sessions.CookieStore
-
oauth *oauth.OAuth
-
posthog posthog.Client
-
}
-
-
func New(
-
config *config.Config,
-
db *db.DB,
-
store *sessions.CookieStore,
-
oauth *oauth.OAuth,
-
posthog posthog.Client,
-
) *OAuthHandler {
-
return &OAuthHandler{
-
config: config,
-
db: db,
-
store: store,
-
oauth: oauth,
-
posthog: posthog,
-
}
-
}
-
-
func (o *OAuthHandler) Router() http.Handler {
-
r := chi.NewRouter()
-
-
r.Get("/login", o.HandleLoginPage)
-
r.Post("/login", o.HandleLoginPage)
-
-
r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
-
-
r.Get("/oauth/client-metadata.json", o.clientMetadata)
-
r.Get("/oauth/jwks.json", o.jwks)
-
r.Get("/oauth/callback", o.callback)
-
-
return r
-
}
-
-
func (o *OAuthHandler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
var user *types.User
-
oauth := o.oauth.GetUser(r)
-
if oauth != nil {
-
bskyProfile, err := bsky.GetBskyProfile(oauth.Did)
-
if err != nil {
-
log.Println("failed to get bsky profile:", err)
-
}
-
user = &types.User{
-
OauthUser: *oauth,
-
BskyProfile: bskyProfile,
-
}
-
}
-
views.LoginPage(views.LoginPageParams{
-
User: user,
-
}).Render(r.Context(), w)
-
case http.MethodPost:
-
err := r.ParseForm()
-
if err != nil {
-
http.Error(w, "Bad Request", http.StatusBadRequest)
-
return
-
}
-
-
handle := r.FormValue("handle")
-
-
// When users copy their handle from bsky.app, it tends to have these
-
// characters around it:
-
// \u202a ensures that the handle is always rendered left to right and
-
// \u202c reverts that so the rest of the page renders however it should
-
handle = strings.TrimPrefix(handle, "\u202a")
-
handle = strings.TrimSuffix(handle, "\u202c")
-
-
handle = strings.TrimPrefix(handle, "@")
-
-
idResolver := atproto.DefaultResolver()
-
resolved, err := idResolver.ResolveIdent(r.Context(), handle)
-
if err != nil {
-
log.Println("failed to resolve handle:", err)
-
htmx.HxError(w, http.StatusBadGateway, fmt.Sprintf("Failed to resolve identity - '%s' is an invalid handle.", handle))
-
return
-
}
-
-
cli := o.oauth.ClientMetadata()
-
oauthClient, err := client.NewClient(
-
cli.ClientID,
-
o.config.OAuth.Jwks,
-
cli.RedirectURIs[0],
-
)
-
if err != nil {
-
log.Println("failed to create oauth client:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
-
if err != nil {
-
log.Println("failed to resolve auth server:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
-
if err != nil {
-
log.Println("failed to fetch auth server metadata:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
dpopKey, err := helpers.GenerateKey(nil)
-
if err != nil {
-
log.Println("failed to generate dpop key:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
dpopKeyJson, err := json.Marshal(dpopKey)
-
if err != nil {
-
log.Println("failed to marshal dpop key:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
-
if err != nil {
-
log.Println("failed to send par auth request:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
err = db.SaveOAuthRequest(o.db, db.OAuthRequest{
-
Did: resolved.DID.String(),
-
PdsUrl: resolved.PDSEndpoint(),
-
Handle: handle,
-
AuthserverIss: authMeta.Issuer,
-
PkceVerifier: parResp.PkceVerifier,
-
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
-
DpopPrivateJwk: string(dpopKeyJson),
-
State: parResp.State,
-
})
-
if err != nil {
-
log.Println("failed to save oauth request:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if !o.config.Core.Dev {
-
err := o.posthog.Enqueue(posthog.Capture{
-
DistinctId: resolved.DID.String(),
-
Event: ph.UserSignInInitiatedEvent,
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
-
query := url.Values{}
-
query.Add("client_id", cli.ClientID)
-
query.Add("request_uri", parResp.RequestUri)
-
u.RawQuery = query.Encode()
-
htmx.HxRedirect(w, u.String())
-
}
-
}
-
-
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
-
did := o.oauth.GetDid(r)
-
err := o.oauth.ClearSession(r, w)
-
if err != nil {
-
log.Println("failed to clear session:", err)
-
http.Redirect(w, r, "/", http.StatusFound)
-
return
-
}
-
-
if !o.config.Core.Dev && did != "" {
-
err := o.posthog.Enqueue(posthog.Capture{
-
DistinctId: did,
-
Event: ph.UserLoggedOutEvent,
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
htmx.HxRedirect(w, "/login")
-
}
-
-
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
-
jwks := o.config.OAuth.Jwks
-
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
-
if err != nil {
-
log.Printf("failed to parse jwks: %v", err)
-
http.Error(w, "Internal Server Error", 500)
-
}
-
-
pubKey, err := k.PublicKey()
-
if err != nil {
-
log.Printf("failed to parse jwks public key: %v", err)
-
http.Error(w, "Internal Server Error", 500)
-
}
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
json.NewEncoder(w).Encode(helpers.CreateJwksResponseObject(pubKey))
-
}
-
-
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
-
}
-
-
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
-
state := r.FormValue("state")
-
-
oauthRequest, err := db.GetOAuthRequestByState(o.db, state)
-
if err != nil {
-
log.Println("failed to get oauth request:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
defer func() {
-
err := db.DeleteOAuthRequestByState(o.db, state)
-
if err != nil {
-
log.Printf("failed to delete oauth request for state '%s': %v", state, err)
-
}
-
}()
-
-
callbackErr := r.FormValue("error")
-
errorDescription := r.FormValue("error_description")
-
if callbackErr != "" || errorDescription != "" {
-
log.Printf("oauth callback error: %s, %s", callbackErr, errorDescription)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
iss := r.FormValue("iss")
-
if iss == "" {
-
log.Println("missing iss for state: ", state)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
code := r.FormValue("code")
-
if code == "" {
-
log.Println("missing code for state: ", state)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if iss != oauthRequest.AuthserverIss {
-
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
cli := o.oauth.ClientMetadata()
-
oauthClient, err := client.NewClient(
-
cli.ClientID,
-
o.config.OAuth.Jwks,
-
cli.RedirectURIs[0],
-
)
-
if err != nil {
-
log.Println("failed to create oauth client:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
-
if err != nil {
-
log.Println("failed to parse jwk:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
tokenResp, err := oauthClient.InitialTokenRequest(
-
r.Context(),
-
code,
-
oauthRequest.AuthserverIss,
-
oauthRequest.PkceVerifier,
-
oauthRequest.DpopAuthserverNonce,
-
jwk,
-
)
-
if err != nil {
-
log.Println("failed to get token:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if tokenResp.Scope != oauthScope {
-
log.Println("oauth scope doesn't match:", tokenResp.Scope)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
userSession, err := o.oauth.SaveSession(w, r, oauthRequest, tokenResp)
-
if err != nil {
-
log.Println("failed to save user session:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if !o.config.Core.Dev {
-
err = o.posthog.Enqueue(posthog.Capture{
-
DistinctId: oauthRequest.Did,
-
Event: ph.UserSignInSuccessEvent,
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
xrpcClient, err := o.oauth.AuthorizedClientFromSession(*userSession, r, w)
-
if err != nil {
-
log.Println("failed to retrieve authorized client:", err)
-
htmx.HxError(w, http.StatusUnauthorized, "Failed to authenticate. Try again later.")
-
return
-
}
-
-
ex, _ := xrpcClient.RepoGetRecord(r.Context(), "", yoten.ActorProfileNSID, oauthRequest.Did, "self")
-
var cid *string
-
if ex != nil {
-
cid = ex.Cid
-
}
-
-
// This should only occur once per account
-
if ex == nil {
-
createdAt := time.Now().Format(time.RFC3339)
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
-
Collection: yoten.ActorProfileNSID,
-
Repo: oauthRequest.Did,
-
Rkey: "self",
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &yoten.ActorProfile{
-
DisplayName: oauthRequest.Handle,
-
Description: db.ToPtr(""),
-
Languages: make([]string, 0),
-
Location: db.ToPtr(""),
-
CreatedAt: createdAt,
-
}},
-
SwapRecord: cid,
-
})
-
if err != nil {
-
log.Println("failed to create record:", err)
-
htmx.HxError(w, http.StatusInternalServerError, "Failed to announce profile creation, try again later")
-
return
-
}
-
-
log.Println("created profile record:", atresp.Uri)
-
if !o.config.Core.Dev {
-
properties := posthog.NewProperties().
-
Set("display_name", oauthRequest.Handle).
-
Set("language_count", 0).
-
Set("$set_once", posthog.NewProperties().
-
Set("initial_did", oauthRequest.Did).
-
Set("initial_handle", oauthRequest.Handle).
-
Set("created_at", createdAt),
-
)
-
-
err = o.posthog.Enqueue(posthog.Identify{
-
DistinctId: oauthRequest.Did,
-
Properties: properties,
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog identify event:", err)
-
}
-
-
err = o.posthog.Enqueue(posthog.Capture{
-
DistinctId: oauthRequest.Did,
-
Event: ph.ProfileRecordCreatedEvent,
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
}
-
-
http.Redirect(w, r, "/", http.StatusFound)
-
}
+150 -217
internal/server/oauth/oauth.go
···
package oauth
import (
+
"errors"
"fmt"
-
"log"
"net/http"
-
"net/url"
"time"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
-
oauth "tangled.sh/icyphox.sh/atproto-oauth"
-
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
-
xrpc "yoten.app/internal/atproto"
-
"yoten.app/internal/db"
"yoten.app/internal/server/config"
-
"yoten.app/internal/server/oauth/client"
"yoten.app/internal/types"
)
type OAuth struct {
-
Store *sessions.CookieStore
-
Db *db.DB
-
Config *config.Config
+
ClientApp *oauth.ClientApp
+
SessionStore *sessions.CookieStore
+
Config *config.Config
+
JwksUri string
}
-
func NewOAuth(db *db.DB, config *config.Config) *OAuth {
-
return &OAuth{
-
Store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
-
Db: db,
-
Config: config,
+
func New(config *config.Config) (*OAuth, error) {
+
var oauthConfig oauth.ClientConfig
+
var clientUri string
+
+
if config.Core.Dev {
+
clientUri = "http://127.0.0.1:" + config.Core.Port
+
callbackUri := clientUri + "/oauth/callback"
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
+
} else {
+
clientUri = config.Core.Host
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
+
callbackUri := clientUri + "/oauth/callback"
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
}
-
}
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) (*sessions.Session, error) {
-
// Save did in user session.
-
userSession, err := o.Store.Get(r, SessionName)
+
jwksUri := clientUri + "/oauth/jwks.json"
+
+
authStore, err := NewRedisStore(config.Redis.ToURL())
if err != nil {
return nil, err
}
-
userSession.Values[SessionDid] = oreq.Did
-
userSession.Values[SessionHandle] = oreq.Handle
-
userSession.Values[SessionPds] = oreq.PdsUrl
-
userSession.Values[SessionAuthenticated] = true
-
err = userSession.Save(r, w)
-
if err != nil {
-
return nil, fmt.Errorf("failed to save user session: %w", err)
-
}
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
-
// Save the whole thing in the db.
-
session := db.OAuthSession{
-
Did: oreq.Did,
-
Handle: oreq.Handle,
-
PdsUrl: oreq.PdsUrl,
-
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
-
AuthServerIss: oreq.AuthserverIss,
-
DpopPrivateJwk: oreq.DpopPrivateJwk,
-
AccessJwt: oresp.AccessToken,
-
RefreshJwt: oresp.RefreshToken,
-
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
-
}
+
return &OAuth{
+
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
+
Config: config,
+
SessionStore: sessStore,
+
JwksUri: jwksUri,
+
}, nil
-
return userSession, db.SaveOAuthSession(o.Db, session)
}
-
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
-
userSession, err := o.Store.Get(r, SessionName)
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessionData *oauth.ClientSessionData) error {
+
// Save did in user session.
+
userSession, err := o.SessionStore.Get(r, SessionName)
if err != nil {
-
return fmt.Errorf("failed to get user session: %w", err)
-
}
-
if userSession.IsNew {
-
return fmt.Errorf("user session is new")
+
return err
}
-
did := userSession.Values[SessionDid].(string)
-
-
err = db.DeleteOAuthSessionByDid(o.Db, did)
+
userSession.Values[SessionDid] = sessionData.AccountDID.String()
+
userSession.Values[SessionPds] = sessionData.HostURL
+
userSession.Values[SessionId] = sessionData.SessionID
+
userSession.Values[SessionAuthenticated] = true
+
err = userSession.Save(r, w)
if err != nil {
-
return fmt.Errorf("failed to delete oauth session: %w", err)
+
return fmt.Errorf("failed to save user session: %w", err)
}
-
userSession.Options.MaxAge = -1
-
-
return userSession.Save(r, w)
+
return nil
}
-
func (o *OAuth) CheckSessionAuth(userSession sessions.Session, r *http.Request) (*db.OAuthSession, bool, error) {
-
did := userSession.Values[SessionDid].(string)
-
auth := userSession.Values[SessionAuthenticated].(bool)
-
-
session, err := db.GetOAuthSessionByDid(o.Db, did)
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
+
userSession, err := o.SessionStore.Get(r, SessionName)
if err != nil {
-
return nil, false, fmt.Errorf("failed to get oauth session: %w", err)
+
return nil, fmt.Errorf("failed to retrieve user session: %w", err)
+
}
+
if userSession.IsNew {
+
return nil, fmt.Errorf("no session available for user")
}
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
+
d := userSession.Values[SessionDid].(string)
+
sessionDid, err := syntax.ParseDID(d)
if err != nil {
-
return nil, false, fmt.Errorf("failed to parse expiry time: %w", err)
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
}
-
if expiry.Sub(time.Now()) <= 5*time.Minute {
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
-
if err != nil {
-
return nil, false, err
-
}
-
-
self := o.ClientMetadata()
-
-
oauthClient, err := client.NewClient(
-
self.ClientID,
-
o.Config.OAuth.Jwks,
-
self.RedirectURIs[0],
-
)
-
-
if err != nil {
-
return nil, false, err
-
}
-
-
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
-
if err != nil {
-
log.Printf("failed to refresh token for did '%s', deleting session: %v", did, err)
-
if delErr := db.DeleteOAuthSessionByDid(o.Db, did); delErr != nil {
-
log.Printf("failed to delete stale oauth session for did '%s': %v", did, delErr)
-
}
-
return nil, false, fmt.Errorf("session expired and could not be refreshed: %w", err)
-
}
-
-
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
-
err = db.RefreshOAuthSession(o.Db, did, resp.AccessToken, resp.RefreshToken, newExpiry)
-
if err != nil {
-
return nil, false, fmt.Errorf("failed to refresh oauth session: %w", err)
-
}
-
-
// Update the current session.
-
session.AccessJwt = resp.AccessToken
-
session.RefreshJwt = resp.RefreshToken
-
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
-
session.Expiry = newExpiry
+
sessionId := userSession.Values[SessionId].(string)
+
+
clientSession, err := o.ClientApp.ResumeSession(r.Context(), sessionDid, sessionId)
+
if err != nil {
+
return nil, fmt.Errorf("failed to resume session: %w", err)
}
-
return session, auth, nil
+
return clientSession, nil
}
-
func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) {
-
userSession, err := o.Store.Get(r, SessionName)
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
+
userSession, err := o.SessionStore.Get(r, SessionName)
if err != nil {
-
return nil, false, fmt.Errorf("failed to get user session: %w", err)
+
return fmt.Errorf("failed to retrieve user session: %w", err)
}
if userSession.IsNew {
-
return nil, false, fmt.Errorf("user session is new")
+
return fmt.Errorf("no session available for user")
}
-
session, auth, err := o.CheckSessionAuth(*userSession, r)
+
d := userSession.Values[SessionDid].(string)
+
sessionDid, err := syntax.ParseDID(d)
if err != nil {
-
return nil, false, fmt.Errorf("failed to check user session auth: %w", err)
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
}
-
return session, auth, nil
+
sessionId := userSession.Values[SessionId].(string)
+
+
// Delete the session.
+
err1 := o.ClientApp.Logout(r.Context(), sessionDid, sessionId)
+
+
// Remove the cookie.
+
userSession.Options.MaxAge = -1
+
err2 := o.SessionStore.Save(r, w, userSession)
+
+
return errors.Join(err1, err2)
}
-
func (a *OAuth) GetUser(r *http.Request) *types.OauthUser {
-
clientSession, err := a.Store.Get(r, SessionName)
+
func (o *OAuth) GetUser(r *http.Request) *types.OauthUser {
+
clientSession, err := o.SessionStore.Get(r, SessionName)
if err != nil || clientSession.IsNew {
return nil
}
return &types.OauthUser{
-
Handle: clientSession.Values[SessionHandle].(string),
-
Did: clientSession.Values[SessionDid].(string),
-
Pds: clientSession.Values[SessionPds].(string),
+
Did: clientSession.Values[SessionDid].(string),
+
Pds: clientSession.Values[SessionPds].(string),
}
}
-
func (a *OAuth) GetDid(r *http.Request) string {
-
clientSession, err := a.Store.Get(r, SessionName)
-
if err != nil || clientSession.IsNew {
-
return ""
+
func (o *OAuth) GetDid(r *http.Request) string {
+
if u := o.GetUser(r); u != nil {
+
return u.Did
}
-
return clientSession.Values[SessionDid].(string)
+
return ""
}
-
func (o *OAuth) AuthorizedClientFromSession(userSession sessions.Session, r *http.Request, w http.ResponseWriter) (*xrpc.Client, error) {
-
session, auth, err := o.CheckSessionAuth(userSession, r)
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
+
session, err := o.ResumeSession(r)
if err != nil {
-
o.ClearSession(r, w)
-
return nil, fmt.Errorf("failed to get session: %w", err)
-
}
-
if !auth {
-
return nil, fmt.Errorf("not authorized")
+
return nil, fmt.Errorf("failed to retrieve session: %w", err)
}
-
client := &oauth.XrpcClient{
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
-
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
-
if err != nil {
-
log.Printf("failed to update dpop pds nonce: %v", err)
-
}
-
},
-
}
+
return session.APIClient(), nil
+
}
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse private jwk: %w", err)
-
}
+
// this is a higher level abstraction on ServerGetServiceAuth
+
type ServiceClientOpts struct {
+
service string
+
exp int64
+
lxm string
+
dev bool
+
}
-
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
-
Did: session.Did,
-
PdsUrl: session.PdsUrl,
-
DpopPdsNonce: session.PdsUrl,
-
AccessToken: session.AccessJwt,
-
Issuer: session.AuthServerIss,
-
DpopPrivateJwk: privateJwk,
-
})
+
type ServiceClientOpt func(*ServiceClientOpts)
-
return xrpcClient, nil
+
func WithService(service string) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.service = service
+
}
}
-
func (o *OAuth) AuthorizedClient(r *http.Request, w http.ResponseWriter) (*xrpc.Client, error) {
-
session, auth, err := o.GetSession(r)
-
if err != nil {
-
o.ClearSession(r, w)
-
return nil, fmt.Errorf("failed to get session: %w", err)
-
}
-
if !auth {
-
return nil, fmt.Errorf("not authorized")
+
// Specify the Duration in seconds for the expiry of this token
+
//
+
// The time of expiry is calculated as time.Now().Unix() + exp
+
func WithExp(exp int64) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.exp = time.Now().Unix() + exp
}
+
}
-
client := &oauth.XrpcClient{
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
-
err := db.UpdateDpopPdsNonce(o.Db, did, newNonce)
-
if err != nil {
-
log.Printf("failed to update dpop pds nonce: %v", err)
-
}
-
},
+
func WithLxm(lxm string) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.lxm = lxm
}
+
}
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
-
if err != nil {
-
return nil, fmt.Errorf("failed to parse private jwk: %w", err)
+
func WithDev(dev bool) ServiceClientOpt {
+
return func(s *ServiceClientOpts) {
+
s.dev = dev
}
-
-
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
-
Did: session.Did,
-
PdsUrl: session.PdsUrl,
-
DpopPdsNonce: session.PdsUrl,
-
AccessToken: session.AccessJwt,
-
Issuer: session.AuthServerIss,
-
DpopPrivateJwk: privateJwk,
-
})
-
-
return xrpcClient, nil
}
-
type ClientMetadata struct {
-
ClientID string `json:"client_id"`
-
ClientName string `json:"client_name"`
-
SubjectType string `json:"subject_type"`
-
ClientURI string `json:"client_uri"`
-
RedirectURIs []string `json:"redirect_uris"`
-
GrantTypes []string `json:"grant_types"`
-
ResponseTypes []string `json:"response_types"`
-
ApplicationType string `json:"application_type"`
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
-
JwksURI string `json:"jwks_uri"`
-
Scope string `json:"scope"`
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
+
func (s *ServiceClientOpts) Audience() string {
+
return fmt.Sprintf("did:web:%s", s.service)
}
-
func (o *OAuth) ClientMetadata() ClientMetadata {
-
makeRedirectURIs := func(c string) []string {
-
return []string{fmt.Sprintf("%s/oauth/callback", c)}
+
func (s *ServiceClientOpts) Host() string {
+
scheme := "https://"
+
if s.dev {
+
scheme = "http://"
}
-
clientURI := o.Config.Core.Host
-
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
-
redirectURIs := makeRedirectURIs(clientURI)
+
return scheme + s.service
+
}
-
if o.Config.Core.Dev {
-
clientURI = "http://127.0.0.1:8080"
-
redirectURIs = makeRedirectURIs(clientURI)
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
+
opts := ServiceClientOpts{}
+
for _, o := range os {
+
o(&opts)
+
}
+
+
client, err := o.AuthorizedClient(r)
+
if err != nil {
+
return nil, err
+
}
-
query := url.Values{}
-
query.Add("redirect_uri", redirectURIs[0])
-
query.Add("scope", "atproto transition:generic")
-
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
+
// force expiry to atleast 60 seconds in the future
+
sixty := time.Now().Unix() + 60
+
if opts.exp < sixty {
+
opts.exp = sixty
}
-
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
-
-
return ClientMetadata{
-
ClientID: clientID,
-
ClientName: "Yoten",
-
SubjectType: "public",
-
ClientURI: clientURI,
-
RedirectURIs: redirectURIs,
-
GrantTypes: []string{"authorization_code", "refresh_token"},
-
ResponseTypes: []string{"code"},
-
ApplicationType: "web",
-
DpopBoundAccessTokens: true,
-
JwksURI: jwksURI,
-
Scope: "atproto transition:generic",
-
TokenEndpointAuthMethod: "private_key_jwt",
-
TokenEndpointAuthSigningAlg: "ES256",
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
+
if err != nil {
+
return nil, err
}
+
+
return &xrpc.Client{
+
Auth: &xrpc.AuthInfo{
+
AccessJwt: resp.Token,
+
},
+
Host: opts.Host(),
+
Client: &http.Client{
+
Timeout: time.Second * 5,
+
},
+
}, nil
}
+148
internal/server/oauth/store.go
···
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/redis/go-redis/v9"
+
)
+
+
// redis-backed implementation of ClientAuthStore.
+
type RedisStore struct {
+
client *redis.Client
+
SessionTTL time.Duration
+
AuthRequestTTL time.Duration
+
}
+
+
var _ oauth.ClientAuthStore = &RedisStore{}
+
+
func NewRedisStore(redisURL string) (*RedisStore, error) {
+
fmt.Println(redisURL)
+
opts, err := redis.ParseURL(redisURL)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
+
}
+
+
client := redis.NewClient(opts)
+
+
// Test the connection.
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+
defer cancel()
+
+
if err := client.Ping(ctx).Err(); err != nil {
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
+
}
+
+
return &RedisStore{
+
client: client,
+
SessionTTL: 30 * 24 * time.Hour, // 30 days
+
AuthRequestTTL: 10 * time.Minute, // 10 minutes
+
}, nil
+
}
+
+
func (r *RedisStore) Close() error {
+
return r.client.Close()
+
}
+
+
func sessionKey(did syntax.DID, sessionID string) string {
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
+
}
+
+
func authRequestKey(state string) string {
+
return fmt.Sprintf("oauth:auth_request:%s", state)
+
}
+
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+
key := sessionKey(did, sessionID)
+
data, err := r.client.Get(ctx, key).Bytes()
+
if err == redis.Nil {
+
return nil, fmt.Errorf("session not found: %s", did)
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get session: %w", err)
+
}
+
+
var sess oauth.ClientSessionData
+
if err := json.Unmarshal(data, &sess); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
+
}
+
+
return &sess, nil
+
}
+
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
+
key := sessionKey(sess.AccountDID, sess.SessionID)
+
+
data, err := json.Marshal(sess)
+
if err != nil {
+
return fmt.Errorf("failed to marshal session: %w", err)
+
}
+
+
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
+
return fmt.Errorf("failed to save session: %w", err)
+
}
+
+
return nil
+
}
+
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+
key := sessionKey(did, sessionID)
+
if err := r.client.Del(ctx, key).Err(); err != nil {
+
return fmt.Errorf("failed to delete session: %w", err)
+
}
+
return nil
+
}
+
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
+
key := authRequestKey(state)
+
data, err := r.client.Get(ctx, key).Bytes()
+
if err == redis.Nil {
+
return nil, fmt.Errorf("request info not found: %s", state)
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
+
}
+
+
var req oauth.AuthRequestData
+
if err := json.Unmarshal(data, &req); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
+
}
+
+
return &req, nil
+
}
+
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+
key := authRequestKey(info.State)
+
+
// check if already exists (to match MemStore behavior)
+
exists, err := r.client.Exists(ctx, key).Result()
+
if err != nil {
+
return fmt.Errorf("failed to check auth request existence: %w", err)
+
}
+
if exists > 0 {
+
return fmt.Errorf("auth request already saved for state %s", info.State)
+
}
+
+
data, err := json.Marshal(info)
+
if err != nil {
+
return fmt.Errorf("failed to marshal auth request: %w", err)
+
}
+
+
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
+
return fmt.Errorf("failed to save auth request: %w", err)
+
}
+
+
return nil
+
}
+
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+
key := authRequestKey(state)
+
if err := r.client.Del(ctx, key).Err(); err != nil {
+
return fmt.Errorf("failed to delete auth request: %w", err)
+
}
+
return nil
+
}
+1 -1
internal/server/views/partials/header.templ
···
class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-48 bg-bg-light border border-bg-dark"
>
<a
-
href={ templ.URL(fmt.Sprintf("/@%s", params.User.Handle)) }
+
href={ templ.URL(fmt.Sprintf("/@%s", params.User.BskyProfile.Handle)) }
class="flex items-center px-4 py-2 text-sm hover:bg-bg gap-2"
>
<i class="w-4 h-4" data-lucide="user"></i>
+2 -3
internal/types/types.go
···
package types
type OauthUser struct {
-
Handle string
-
Did string
-
Pds string
+
Did string
+
Pds string
}
type User struct {