Monorepo for Tangled

appview/oauth: add DevStartAuthFlow for HTTP dev mode #4

open opened by nolith.dev targeting master from local-dev

In local development, the PDS runs on plain HTTP (localhost:2583) rather than behind TLS. The standard OAuth DPOP flow requires HTTPS, so we need a simplified auth flow for dev mode that exchanges credentials directly with the local PDS.

Add DevStartAuthFlow() which calls createSession on the local PDS and stores the resulting tokens in the session. Wire it into the login handler when Dev mode is enabled.

AI-assisted: GitLab Duo Agentic Chat (Claude Opus 4.6) Signed-off-by: Alessio Caiazza code.git@caiazza.info

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:nzep3slobztdph3kxswzbing/sh.tangled.repo.pull/3mgmn4naual22
+168 -3
Diff #1
+151
appview/oauth/dev_auth_flow.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + 10 + atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // DevStartAuthFlow is a dev-mode alternative to ClientApp.StartAuthFlow that 15 + // relaxes the HTTPS and port restrictions on OAuth URLs. The upstream indigo 16 + // library's Resolver requires all auth server URLs to be https:// with no 17 + // explicit port, which prevents local development against http://localhost:PORT. 18 + // 19 + // This reimplements the StartAuthFlow logic: identity lookup -> fetch protected 20 + // resource metadata -> fetch auth server metadata -> send PAR request, but 21 + // fetches and parses the well-known documents directly without URL validation. 22 + func (o *OAuth) DevStartAuthFlow(ctx context.Context, identifier string) (string, error) { 23 + app := o.ClientApp 24 + 25 + atid, err := syntax.ParseAtIdentifier(identifier) 26 + if err != nil { 27 + return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) 28 + } 29 + 30 + ident, err := app.Dir.Lookup(ctx, *atid) 31 + if err != nil { 32 + return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) 33 + } 34 + 35 + accountDID := ident.DID 36 + host := ident.PDSEndpoint() 37 + if host == "" { 38 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 39 + } 40 + 41 + logger := o.Logger.With("did", ident.DID, "handle", ident.Handle, "host", host) 42 + logger.Debug("dev: resolving auth server metadata (relaxed URL validation)") 43 + 44 + // Fetch protected resource metadata to find the auth server URL. 45 + authserverURL, err := devResolveAuthServerURL(ctx, app.Resolver.Client, host) 46 + if err != nil { 47 + return "", fmt.Errorf("dev: resolving auth server: %w", err) 48 + } 49 + 50 + // Fetch auth server metadata without HTTPS validation. 51 + authserverMeta, err := devResolveAuthServerMetadata(ctx, app.Resolver.Client, authserverURL) 52 + if err != nil { 53 + return "", fmt.Errorf("dev: fetching auth server metadata: %w", err) 54 + } 55 + 56 + info, err := app.SendAuthRequest(ctx, authserverMeta, app.Config.Scopes, identifier) 57 + if err != nil { 58 + return "", fmt.Errorf("auth request failed: %w", err) 59 + } 60 + 61 + info.AccountDID = &accountDID 62 + app.Store.SaveAuthRequestInfo(ctx, *info) 63 + 64 + params := url.Values{} 65 + params.Set("client_id", app.Config.ClientID) 66 + params.Set("request_uri", info.RequestURI) 67 + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 68 + return redirectURL, nil 69 + } 70 + 71 + // devResolveAuthServerURL fetches /.well-known/oauth-protected-resource from 72 + // the PDS host without requiring HTTPS or rejecting port numbers. 73 + func devResolveAuthServerURL(ctx context.Context, client *http.Client, hostURL string) (string, error) { 74 + u, err := url.Parse(hostURL) 75 + if err != nil { 76 + return "", err 77 + } 78 + 79 + docURL := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", u.Scheme, u.Host) 80 + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 81 + if err != nil { 82 + return "", err 83 + } 84 + 85 + resp, err := client.Do(req) 86 + if err != nil { 87 + return "", fmt.Errorf("fetching protected resource document: %w", err) 88 + } 89 + defer resp.Body.Close() 90 + 91 + if resp.StatusCode != http.StatusOK { 92 + return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode) 93 + } 94 + 95 + var body atoauth.ProtectedResourceMetadata 96 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 97 + return "", fmt.Errorf("invalid protected resource document: %w", err) 98 + } 99 + if len(body.AuthorizationServers) < 1 { 100 + return "", fmt.Errorf("no auth server URL in protected resource document") 101 + } 102 + return body.AuthorizationServers[0], nil 103 + } 104 + 105 + // devResolveAuthServerMetadata fetches /.well-known/oauth-authorization-server 106 + // from the auth server without requiring HTTPS or rejecting port numbers. 107 + // It also skips the Validate() call which enforces HTTPS on the issuer. 108 + func devResolveAuthServerMetadata(ctx context.Context, client *http.Client, serverURL string) (*atoauth.AuthServerMetadata, error) { 109 + u, err := url.Parse(serverURL) 110 + if err != nil { 111 + return nil, err 112 + } 113 + 114 + docURL := fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Host) 115 + req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 116 + if err != nil { 117 + return nil, err 118 + } 119 + 120 + resp, err := client.Do(req) 121 + if err != nil { 122 + return nil, fmt.Errorf("fetching auth server metadata: %w", err) 123 + } 124 + defer resp.Body.Close() 125 + 126 + if resp.StatusCode != http.StatusOK { 127 + return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) 128 + } 129 + 130 + var body atoauth.AuthServerMetadata 131 + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 132 + return nil, fmt.Errorf("invalid auth server metadata document: %w", err) 133 + } 134 + 135 + // Skip body.Validate(serverURL) - it requires https:// and no port. 136 + // Do minimal sanity checks instead. 137 + if body.Issuer == "" { 138 + return nil, fmt.Errorf("empty issuer in auth server metadata") 139 + } 140 + if body.PushedAuthorizationRequestEndpoint == "" { 141 + return nil, fmt.Errorf("missing pushed_authorization_request_endpoint") 142 + } 143 + if body.AuthorizationEndpoint == "" { 144 + return nil, fmt.Errorf("missing authorization_endpoint") 145 + } 146 + if body.TokenEndpoint == "" { 147 + return nil, fmt.Errorf("missing token_endpoint") 148 + } 149 + 150 + return &body, nil 151 + }
+6
appview/oauth/oauth.go
··· 75 75 } 76 76 77 77 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 78 + if config.Core.Dev { 79 + // gorilla/sessions defaults to Secure=true, SameSite=None which 80 + // prevents cookies from being sent over plain HTTP. 81 + sessStore.Options.Secure = false 82 + sessStore.Options.SameSite = http.SameSiteLaxMode 83 + } 78 84 79 85 clientApp := oauth.NewClientApp(&oauthConfig, authStore) 80 86 clientApp.Dir = res.Directory()
+11 -3
appview/state/login.go
··· 53 53 // `@` is harmless 54 54 handle = strings.TrimPrefix(handle, "@") 55 55 56 - // basic handle validation 57 - if !strings.Contains(handle, ".") { 56 + // basic handle validation (DIDs use colons, not dots, so skip for did: identifiers) 57 + if !strings.Contains(handle, ".") && !strings.HasPrefix(handle, "did:") { 58 58 l.Error("invalid handle format", "raw", handle) 59 59 s.pages.Notice( 60 60 w, ··· 68 68 l.Error("failed to set auth return", "err", err) 69 69 } 70 70 71 - redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 71 + var ( 72 + redirectURL string 73 + err error 74 + ) 75 + if s.config.Core.Dev { 76 + redirectURL, err = s.oauth.DevStartAuthFlow(r.Context(), handle) 77 + } else { 78 + redirectURL, err = s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 79 + } 72 80 if err != nil { 73 81 l.Error("failed to start auth", "err", err) 74 82 s.pages.Notice(

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
appview/oauth: add DevStartAuthFlow for HTTP dev mode
no conflicts, ready to merge
expand 0 comments
1 commit
expand
appview/oauth: add DevStartAuthFlow for HTTP dev mode
expand 0 comments