oauth

package module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 5, 2025 License: MIT Imports: 20 Imported by: 4

README

Atproto OAuth Golang

[!WARNING]
This is an experimental repo. It may contain bugs. Use at your own risk.

[!WARNING] You should always validate user input. The example/test code inside this repo may be used as an implementation guide, but no guarantees are made.

go get github.com/haileyok/atproto-oauth-golang

Prerequisites

There are some prerequisites that you'll need to handle before implementing this OAuth client.

Private JWK

If you do not already have a private JWK for your application, first create one. There is a helper CLI tool that can generate one for you. From the project directory, run

make jwks

You will need to read the JWK from your application and parse it using oauth.ParseJWKFromBytes.

Serve client-metadata.json from your application

The client metadata will need to be accessible from your domain. An example using echo is below.

func (s *TestServer) handleClientMetadata(e echo.Context) error {
	metadata := map[string]any{
		"client_id":                       serverMetadataUrl,
		"client_name":                     "Atproto Oauth Golang Tester",
		"client_uri":                      serverUrlRoot,
		"logo_uri":                        fmt.Sprintf("%s/logo.png", serverUrlRoot),
		"tos_uri":                         fmt.Sprintf("%s/tos", serverUrlRoot),
		"policy_url":                      fmt.Sprintf("%s/policy", serverUrlRoot),
		"redirect_uris":                   []string{serverCallbackUrl},
		"grant_types":                     []string{"authorization_code", "refresh_token"},
		"response_types":                  []string{"code"},
		"application_type":                "web",
		"dpop_bound_access_tokens":        true,
		"jwks_uri":                        fmt.Sprintf("%s/oauth/jwks.json", serverUrlRoot),
		"scope":                           "atproto transition:generic",
		"token_endpoint_auth_method":      "private_key_jwt",
		"token_endpoint_auth_signing_alg": "ES256",
	}

	return e.JSON(200, metadata)
}
Serve jwks.json

You will also need to serve your private JWK's public key from your domain. Again, an example is below.

func (s *TestServer) handleJwks(e echo.Context) error {
    b, err := os.ReadFile("./jwk.json")
    if err != nil {
        return err
    }

    k, err := helpers.ParseJWKFromBytes(b)
    if err != nil {
        return err
    }

    pubKey, err := k.PublicKey()
    if err != nil {
        return err
    }

    return e.JSON(200, helpers.CreateJwksResponseObject(pubKey))
}

Usage

Once you have completed the prerequisites, you can implement and use the client.

Create a new OAuth Client

Create an OAuth client by calling oauth.NewClient

clientId := "https://yourdomain.com/path/to/client-metadata.json"
callbackUrl := "https://yourdomain.com/oauth-callback"

b, err := os.ReadFile("./jwks.json")
if err != nil {
    return err
}

k, err := helpers.ParseJWKFromBytes(b)
if err != nil {
    return err
}

cli, err := helpers.NewClient(oauth.ClientArgs{
    ClientJwk: k,
    ClientId: clientId,
    RedirectUri: callbackUrl,
})
if err != nil {
    return err
}
Starting Authenticating

There are examples of the authentication flow inside of cmd/client_tester/handle_auth.go, however we'll talk about some general points here.

Determining the user's PDS

You should allow for users to input their handle, DID, or PDS URL when detemrining where to send the user for authentication. An example that covers all the bases of what you'll need to do is when a user uses their handle.

cli := oauth.NewClient()
userInput := "hailey.at"

// If you already have a did or a URL, you can skip this step
did, err := resolveHandle(ctx, userInput) // returns did:plc:abc123 or did:web:test.com
if err != nil {
    return err
}

// If you already have a URL, you can skip this step
service, err := resolveService(ctx, did) // returns https://pds.haileyok.com
if err != nil {
    return err
}

authserver, err := cli.ResolvePdsAuthServer(ctx, service)
if err != nil {
    return err
}

authmeta, err := cli.FetchAuthServerMetadata(ctx, authserver)
if err != nil {
    return err
}

By this point, you will have the necessary information to direct the user where they need to go.

Create a private DPoP JWK for the user

You'll need to create a private DPoP JWK for the user before directing them to their PDS to authenticate. You'll need to store this in a later step, and you will need to pass it along inside the PAR request, so go ahead and marshal it as well.

k, err := helpers.GenerateKey(nil)
if err != nil {
    return err
}

b, err := json.Marshal(k)
if err != nil {
    return err
}
Make the PAR request
// Note: the login hint - here `handle` - should only be set if you have a DID or handle. Leave it empty if all you
// have is the PDS url.
parResp, err := cli.SendParAuthRequest(ctx, authserver, authmeta, handle, scope, dpopPrivateKey)
if err != nil {
    return err
}
Store the needed information before redirecting

Some items will need to be stored for later when the PDS redirects to your application.

  • The user's DID, if you have it
  • The user's PDS url
  • The authserver issuer
  • The state value from the PAR request
  • The PKCE verifier from the PAR rquest
  • The DPoP autherserver nonce from the PAR request
  • The DPoP private JWK thhat you generated

It is up to you how you want to store these values. Most likely, you will want to store them in a database. You may also want to store the state variable in the user's session as well as the database so you can verify it later. There's a basic implementation inside of cmd/client_tester/handle_auth.go.

Redirect

Once you've stored the needed info, send the user to their PDS. The URL to redirect the user to should have both the client_id and request_uri GET parameters set.

u, _ := url.Parse(meta.AuthorizationEndpoint)
u.RawQuery = fmt.Sprintf("client_id=%s&requires_uri=%s", url.QueryEscape(yourClientId), parResp.RequestUri)

// Redirect the user to created url
Callback handling

Handling the response is pretty easy, though you'll want to check a few things once you receive the response.

  • Ensure that state, iss, and code are present in the GET parameters
  • Ensure that the state value matches the state value you stored before redirection

You'll next need to load all of the request information you previously stored. Once you have that information, you can perform the initial token request.

resCode := e.QueryParam("code")
resIss := e.QueryParam("iss")

itResp, err := cli.InitialTokenRequest(ctx, resCode, resIss, requestInfo.PkceVerifier, requestInfo.DpopAuthserverNonce, requestInfo.privateJwk)
if err != nil {
    return err
}
Final checks

Finally, check that the scope received matches the requested scope. Also, if you didn't have the user's DID before redirecting earlier, you can now get their DID from itResp.Sub.

if itResp.Scope != requestedScope {
    return fmt.Errorf("bad scope")
}

if requestInfo.Did == "" {
    // Do something...
}
Store the response

Now, you can store the response items to make make authenticated requests later. You likely will want to store at least the user's DID in a secure session so that you know who the user is.

Refreshing the token

The acess token you receive will expire after one hour and you will need to refresh it. You may choose to create a helper method that will refresh the token as necessary whenever you fetch the authentication information from your store. For an example, see cmd/client_test/user.go.

Making requests

You may have some experience using the atproto SDK's helper methods from indigo. For example, you may be able to call ActorGetProfile() to fetch a user's profile. Currently, the atproto SDK does not support OAuth however, and will need some changes. In the meantime, I have added a custom XRPC client to this repo that can be used with OAuth sessions created in this library.

Creating an XRPC client

Similar to the indigo/xrpc package, you can create an XRPC client like so

client := &oauth.XrpcClient{
    OnDpopPdsNonceChanged: func(did, newNonce string) {
	// Handle updating your store with the new nonce
    },
}

The OnDpopPdsNonceChanged callback will fire whenever an authenticated request results in an updated DPoP PDS nonce. You should update your store with this nonce for future requests.

Making requests

Instead of using "helpers", for now you should make requests by simply calling Do() on the XRPC client. You will need to pass XrpcAuthedRequestArgs to the function to perform authenticated requests. If the parameter is nil, the request will be made unauthenticated. A few examples are below.

Creating authentication arguments
// Get your user's session - however you are doing that - and retrieve their did

// Grab the oauth session from your database
oauthSession, err := s.getOauthSession(e.Request().Context(), did)

// Parse the user's JWK to pass into arguments
privateJwk, err := helpers.ParseJWKFromBytes([]byte(oauthSession.DpopPrivateJwk))
if err != nil {
	return nil, false, err
}

return &oauth.XrpcAuthedRequestArgs{
	Did:            oauthSession.Did,
	AccessToken:    oauthSession.AccessToken,
	PdsUrl:         oauthSession.PdsUrl,
	Issuer:         oauthSession.AuthserverIss,
	DpopPdsNonce:   oauthSession.DpopPdsNonce,
	DpopPrivateJwk: privateJwk,
}, nil
Making a post
authArgs, err := s.getOauthSessionAuthArgs(e)
if err != nil {
	return err
}

post := bsky.FeedPost{
	Text:      "hello from atproto golang oauth client",
	CreatedAt: syntax.DatetimeNow().String(),
}

input := atproto.RepoCreateRecord_Input{
	Collection: "app.bsky.feed.post",
	Repo:       authArgs.Did,
	Record:     &util.LexiconTypeDecoder{Val: &post},
}

var out atproto.RepoCreateRecord_Output
if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil {
	return err
}
Getting a profile
authArgs, err := s.getOauthSessionAuthArgs(e)
if err != nil {
	return err
}

var out bsky.ActorDefs_ProfileViewDetailed
if err := s.xrpcCli.Do(e.Request().Context(), authArgs, xrpc.Query, "", "app.bsky.actor.getProfile", map[string]any{"actor": authArgs.Did}, nil, &out); err != nil {
	return err
}

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func PdsDpopJwt

func PdsDpopJwt(method, url, iss, accessToken, nonce string, privateJwk jwk.Key) (string, error)

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

func NewClient

func NewClient(args ClientArgs) (*Client, error)

func (*Client) AuthServerDpopJwt

func (c *Client) AuthServerDpopJwt(method, url, nonce string, privateJwk jwk.Key) (string, error)

func (*Client) ClientAssertionJwt

func (c *Client) ClientAssertionJwt(authServerUrl string) (string, error)

func (*Client) FetchAuthServerMetadata

func (c *Client) FetchAuthServerMetadata(ctx context.Context, ustr string) (*OauthAuthorizationMetadata, error)

func (*Client) InitialTokenRequest

func (c *Client) InitialTokenRequest(
	ctx context.Context,
	code,
	authserverIss,
	pkceVerifier,
	dpopAuthserverNonce string,
	dpopPrivateJwk jwk.Key,
) (*TokenResponse, error)

func (*Client) RefreshTokenRequest

func (c *Client) RefreshTokenRequest(
	ctx context.Context,
	refreshToken,
	authserverIss,
	dpopAuthserverNonce string,
	dpopPrivateJwk jwk.Key,
) (*TokenResponse, error)

func (*Client) ResolvePdsAuthServer

func (c *Client) ResolvePdsAuthServer(ctx context.Context, ustr string) (string, error)

func (*Client) SendParAuthRequest

func (c *Client) SendParAuthRequest(ctx context.Context, authServerUrl string, authServerMeta *OauthAuthorizationMetadata, loginHint, scope string, dpopPrivateKey jwk.Key) (*SendParAuthResponse, error)

type ClientArgs

type ClientArgs struct {
	Http        *http.Client
	ClientJwk   jwk.Key
	ClientId    string
	RedirectUri string
}

type OauthAuthorizationMetadata

type OauthAuthorizationMetadata struct {
	Issuer                                     string   `json:"issuer"`
	RequestParameterSupported                  bool     `json:"request_parameter_supported"`
	RequestUriParameterSupported               bool     `json:"request_uri_parameter_supported"`
	RequireRequestUriRegistration              *bool    `json:"require_request_uri_registration,omitempty"`
	ScopesSupported                            []string `json:"scopes_supported"`
	SubjectTypesSupported                      []string `json:"subject_types_supported"`
	ResponseTypesSupported                     []string `json:"response_types_supported"`
	ResponseModesSupported                     []string `json:"response_modes_supported"`
	GrantTypesSupported                        []string `json:"grant_types_supported"`
	CodeChallengeMethodsSupported              []string `json:"code_challenge_methods_supported"`
	UILocalesSupported                         []string `json:"ui_locales_supported"`
	DisplayValuesSupported                     []string `json:"display_values_supported"`
	RequestObjectSigningAlgValuesSupported     []string `json:"request_object_signing_alg_values_supported"`
	AuthorizationResponseISSParameterSupported bool     `json:"authorization_response_iss_parameter_supported"`
	RequestObjectEncryptionAlgValuesSupported  []string `json:"request_object_encryption_alg_values_supported"`
	RequestObjectEncryptionEncValuesSupported  []string `json:"request_object_encryption_enc_values_supported"`
	JwksUri                                    string   `json:"jwks_uri"`
	AuthorizationEndpoint                      string   `json:"authorization_endpoint"`
	TokenEndpoint                              string   `json:"token_endpoint"`
	TokenEndpointAuthMethodsSupported          []string `json:"token_endpoint_auth_methods_supported"`
	TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
	RevocationEndpoint                         string   `json:"revocation_endpoint"`
	IntrospectionEndpoint                      string   `json:"introspection_endpoint"`
	PushedAuthorizationRequestEndpoint         string   `json:"pushed_authorization_request_endpoint"`
	RequirePushedAuthorizationRequests         bool     `json:"require_pushed_authorization_requests"`
	DpopSigningAlgValuesSupported              []string `json:"dpop_signing_alg_values_supported"`
	ProtectedResources                         []string `json:"protected_resources"`
	ClientIDMetadataDocumentSupported          bool     `json:"client_id_metadata_document_supported"`
}

func (*OauthAuthorizationMetadata) UnmarshalJSON

func (oam *OauthAuthorizationMetadata) UnmarshalJSON(b []byte) error

func (*OauthAuthorizationMetadata) Validate

func (oam *OauthAuthorizationMetadata) Validate(fetch_url *url.URL) error

type OauthProtectedResource

type OauthProtectedResource struct {
	Resource               string   `json:"resource"`
	AuthorizationServers   []string `json:"authorization_servers"`
	ScopesSupported        []string `json:"scopes_supported"`
	BearerMethodsSupported []string `json:"bearer_methods_supported"`
	ResourceDocumentation  string   `json:"resource_documentation"`
}

func (*OauthProtectedResource) UnmarshalJSON

func (opr *OauthProtectedResource) UnmarshalJSON(b []byte) error

type RefreshTokenArgs

type RefreshTokenArgs struct {
	AuthserverUrl       string
	RefreshToken        string
	DpopPrivateJwk      string
	DpopAuthserverNonce string
}

type SendParAuthResponse

type SendParAuthResponse struct {
	PkceVerifier        string
	State               string
	DpopAuthserverNonce string
	ExpiresIn           float64
	RequestUri          string
}

type TokenResponse

type TokenResponse struct {
	DpopAuthserverNonce string `json:"-"`
	AccessToken         string `json:"access_token"`
	RefreshToken        string `json:"refresh_token"`
	ExpiresIn           int    `json:"expires_in"`
	Scope               string `json:"scope"`
	Sub                 string `json:"sub"`
	TokenType           string `json:"token_type"`
}

type XrpcAuthedRequestArgs

type XrpcAuthedRequestArgs struct {
	Did            string
	PdsUrl         string
	Issuer         string
	AccessToken    string
	DpopPdsNonce   string
	DpopPrivateJwk jwk.Key
}

type XrpcClient

type XrpcClient struct {
	// Client is an HTTP client to use. If not set, defaults to http.RobustHTTPClient().
	Client                *http.Client
	UserAgent             *string
	Headers               map[string]string
	OnDpopPdsNonceChanged func(did, newNonce string)
}

func (*XrpcClient) Do

func (c *XrpcClient) Do(ctx context.Context, authedArgs *XrpcAuthedRequestArgs, kind xrpc.XRPCRequestType, inpenc, method string, params map[string]any, bodyobj any, out any) error

Directories

Path Synopsis
cmd
helper command
web_server_demo command
internal

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL