{"id":37116990,"url":"https://github.com/openframebox/goauth","last_synced_at":"2026-01-14T13:42:09.093Z","repository":{"id":324419126,"uuid":"1097160033","full_name":"openframebox/goauth","owner":"openframebox","description":"Pluggable authentication for Go. Build username/password or JWT-based auth with a simple strategy interface, a configurable JWT access-token + refresh-token issuer, and typed errors for clean, predictable error handling.","archived":false,"fork":false,"pushed_at":"2025-11-22T19:11:48.000Z","size":59,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-11-22T20:32:41.974Z","etag":null,"topics":["authentication","authorization","golang"],"latest_commit_sha":null,"homepage":"https://github.com/openframebox/goauth","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/openframebox.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-11-15T16:40:40.000Z","updated_at":"2025-11-22T19:09:46.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/openframebox/goauth","commit_stats":null,"previous_names":["openframebox/goauth"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/openframebox/goauth","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openframebox%2Fgoauth","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openframebox%2Fgoauth/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openframebox%2Fgoauth/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openframebox%2Fgoauth/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/openframebox","download_url":"https://codeload.github.com/openframebox/goauth/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openframebox%2Fgoauth/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28421729,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-14T13:30:50.153Z","status":"ssl_error","status_checked_at":"2026-01-14T13:29:08.907Z","response_time":107,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["authentication","authorization","golang"],"created_at":"2026-01-14T13:42:08.455Z","updated_at":"2026-01-14T13:42:09.078Z","avatar_url":"https://github.com/openframebox.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# goauth\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/openframebox/goauth/v2.svg)](https://pkg.go.dev/github.com/openframebox/goauth/v2)\n\nPluggable authentication for Go. Build username/password or JWT-based auth with a simple strategy interface, configurable JWT access/refresh token issuers with multi-session support, and typed errors for clean, predictable error handling.\n\nWorks as an auth core you can drop into HTTP APIs, gRPC, or CLIs.\n\n## Version\n\n**Current: v2.0.0**\n\nThis is a major version with breaking changes from v1. See [Migration from v1](#migration-from-v1) for upgrade guide.\n\n## Installation\n\n```bash\ngo get github.com/openframebox/goauth/v2\n```\n\nFor v1 (legacy):\n\n```bash\ngo get github.com/openframebox/goauth\n```\n\n## Features\n\n- **Multi-session support** - Users can have multiple active sessions (e.g., phone + laptop)\n- **Token rotation** - Proper refresh token rotation with old token invalidation\n- **Multiple signing algorithms** - HS256/384/512, RS256/384/512, ES256/384/512\n- **Event hooks** - `OnBeforeAuthenticate`, `OnAfterAuthenticate`, `OnTokenIssued`, `OnTokenRevoked`\n- **Rate limiting** - Built-in interfaces for rate limiting strategies\n- **Password validation** - Optional bcrypt/argon2 integration\n- **Thread-safe** - Safe for concurrent use\n- **Typed errors** - Categorized errors for consistent HTTP responses\n\n## Concepts\n\n- **Strategy**: pluggable auth mechanism (Local, JWT, OAuth, SSO). Implement `Name()` and `Authenticate()`.\n- **Authenticatable**: minimal user interface (`GetID`, `GetUsername`, `GetEmail`, `GetExtra`).\n- **TokenIssuer**: creates/verifies access tokens and manages refresh tokens.\n  - `DefaultTokenIssuer`: basic HS256 JWT issuer\n  - `SessionTokenIssuer`: multi-session aware issuer with configurable signing\n- **SessionInfo**: session metadata (ID, device, IP, expiry) for multi-session support\n- **Typed Errors**: `CredentialError`, `TokenError`, `ConfigError`, `NotFoundError`, `InternalError`, `RateLimitError`, `ValidationError`, `SessionError`\n\n## Quick Start\n\n```\ngo run ./example                    # Basic multi-session demo\ngo run ./example/http_server        # HTTP server example\n```\n\n## Basic Setup (DefaultTokenIssuer)\n\nFor simple use cases without multi-session support:\n\n```go\npackage main\n\nimport (\n    \"context\"\n    goauth \"github.com/openframebox/goauth/v2\"\n)\n\nfunc setup() *goauth.GoAuth {\n    // Configure token issuer\n    ti := goauth.NewDefaultTokenIssuer(\"supersecret\")\n    ti.SetIssuer(\"api.example.com\")\n    ti.SetAudience([]string{\"api.example.com\"})\n\n    // Required: refresh token storage\n    ti.StoreRefreshTokenWith(func(ctx context.Context, a goauth.Authenticatable, tok *goauth.Token, oldToken *string) error {\n        // oldToken is nil for initial login, non-nil for refresh (rotation)\n        if oldToken != nil {\n            // Invalidate the old token\n        }\n        // Store tok.Value with user a.GetID()\n        return nil\n    })\n\n    ti.ValidateRefreshTokenWith(func(ctx context.Context, token string) (goauth.Authenticatable, error) {\n        // Lookup token -\u003e user; return error if invalid\n        return \u0026goauth.User{ID: \"user-123\"}, nil\n    })\n\n    ti.RevokeRefreshTokenWith(func(ctx context.Context, token string) error {\n        // Delete the token from storage\n        return nil\n    })\n\n    // Build orchestrator\n    ga := goauth.New()\n    ga.SetTokenIssuer(ti)\n\n    // Register strategies using builder pattern\n    ga.RegisterStrategy(goauth.NewLocalStrategy(func(ctx context.Context, p goauth.AuthParams) (goauth.Authenticatable, error) {\n        // Validate credentials\n        return \u0026goauth.User{ID: \"user-\" + p.UsernameOrEmail, Username: p.UsernameOrEmail}, nil\n    }))\n\n    ga.RegisterStrategy(goauth.NewJWTStrategy(ti).WithExpectedType(goauth.AccessToken))\n\n    return ga\n}\n```\n\n## Multi-Session Setup (SessionTokenIssuer)\n\nFor apps that need multi-device login, session management, and advanced signing:\n\n```go\npackage main\n\nimport (\n    \"context\"\n    \"time\"\n    goauth \"github.com/openframebox/goauth/v2\"\n)\n\nfunc setup() *goauth.GoAuth {\n    // Create key provider (supports HS256/384/512, RS256/384/512, ES256/384/512)\n    keyProvider, _ := goauth.NewHMACKeyProvider([]byte(\"supersecret\"), goauth.HS256)\n\n    // Build session-aware token issuer\n    issuer, _ := goauth.NewSessionAwareTokenIssuer().\n        WithKeyProvider(keyProvider).\n        WithIssuer(\"api.example.com\").\n        WithAudience([]string{\"api.example.com\"}).\n        WithAccessTokenTTL(15 * time.Minute).\n        WithRefreshTokenTTL(7 * 24 * time.Hour).\n        WithSessionStore(\n            storeSession,      // Store session + token\n            validateSession,   // Validate token -\u003e user + session\n            revokeSession,     // Revoke single session\n            revokeAllSessions, // Revoke all user sessions\n        ).\n        WithListSessions(listSessions).\n        WithGetSession(getSession).\n        WithSessionMetadataExtractor(func(ctx context.Context) map[string]any {\n            // Extract device info, IP, user agent from context\n            return map[string]any{\"device\": \"browser\", \"ip\": \"127.0.0.1\"}\n        }).\n        Build()\n\n    ga := goauth.New()\n    ga.SetTokenIssuer(issuer)\n\n    // Register strategies\n    ga.RegisterStrategy(goauth.NewLocalStrategy(lookupUser))\n    ga.RegisterStrategy(goauth.NewJWTStrategy(issuer).WithExpectedType(goauth.AccessToken))\n\n    return ga\n}\n\n// Session store callbacks\nfunc storeSession(ctx context.Context, auth goauth.Authenticatable, session *goauth.SessionInfo, token *goauth.Token, oldToken *string) error {\n    // If oldToken != nil, invalidate it (rotation)\n    // Store session with token\n    return nil\n}\n\nfunc validateSession(ctx context.Context, token string) (goauth.Authenticatable, *goauth.SessionInfo, error) {\n    // Lookup token -\u003e user + session\n    return user, session, nil\n}\n\nfunc revokeSession(ctx context.Context, auth goauth.Authenticatable, sessionID string) error {\n    // Delete session by ID\n    return nil\n}\n\nfunc revokeAllSessions(ctx context.Context, auth goauth.Authenticatable) error {\n    // Delete all sessions for user\n    return nil\n}\n\nfunc listSessions(ctx context.Context, auth goauth.Authenticatable) ([]*goauth.SessionInfo, error) {\n    // Return all active sessions for user\n    return sessions, nil\n}\n\nfunc getSession(ctx context.Context, token string) (*goauth.SessionInfo, error) {\n    // Get session info by token\n    return session, nil\n}\n```\n\n## Choosing a Token Issuer\n\n| Feature                 | DefaultTokenIssuer   | SessionTokenIssuer                                   |\n| ----------------------- | -------------------- | ---------------------------------------------------- |\n| **Signing algorithms**  | HS256 only           | HS256/384/512, RS256/384/512, ES256/384/512          |\n| **Multi-device login**  | No session isolation | Each device = unique session                         |\n| **Session management**  | None                 | `ListSessions`, `RevokeSession`, `RevokeAllSessions` |\n| **JWT `sid` claim**     | Not included         | Session ID embedded in access token                  |\n| **Session metadata**    | None                 | Device, IP, user agent tracking                      |\n| **Configuration style** | Setter methods       | Builder pattern                                      |\n| **Storage callbacks**   | Token-centric        | Session-centric                                      |\n\n**Use `DefaultTokenIssuer` when:**\n\n- Simple single-session apps\n- You only need basic JWT with HS256\n- You manage token storage yourself without session semantics\n\n**Use `SessionTokenIssuer` when:**\n\n- Users log in from multiple devices (phone + laptop)\n- You need \"see all active sessions\" or \"logout all devices\" features\n- You want flexible signing algorithms (RSA, ECDSA)\n- You need session metadata (device info, IP tracking)\n\n## Core Flows\n\n### 1) Login and Issue Tokens\n\n```go\n// Returns individual tokens\nres, access, refresh, err := ga.AuthenticateAndIssueTokens(ctx, \"local\", goauth.AuthParams{\n    UsernameOrEmail: \"alice\",\n    Password:        \"s3cret\",\n})\n\n// Or returns TokenPair\nres, pair, err := ga.AuthenticateAndIssueTokenPair(ctx, \"local\", params)\n// pair.Access, pair.Refresh, pair.Access.SessionID\n```\n\n### 2) Authenticate Requests with JWT\n\n```go\nres, err := ga.Authenticate(ctx, \"jwt\", goauth.AuthParams{Token: bearer})\n// res.Authenticatable is your user\n```\n\n### 3) Refresh Tokens (with rotation)\n\n```go\n// Old refresh token is passed to storage for invalidation\npair, err := ga.RefreshTokenPair(ctx, refreshToken)\n// pair.Access (new), pair.Refresh (new, old is invalidated)\n```\n\n### 4) Revoke Tokens / Sessions\n\n```go\n// Revoke single token\nerr := ga.RevokeToken(ctx, refreshToken)\n\n// Revoke specific session (requires SessionTokenIssuer)\nerr := ga.RevokeSession(ctx, user, sessionID)\n\n// Revoke all sessions (logout everywhere)\nerr := ga.RevokeAllTokens(ctx, user)\n```\n\n### 5) List Active Sessions\n\n```go\nsessions, err := ga.ListSessions(ctx, user)\nfor _, s := range sessions {\n    fmt.Printf(\"Session %s: device=%s, expires=%s\\n\",\n        s.ID, s.Metadata[\"device\"], s.ExpiresAt)\n}\n```\n\n## Event Hooks\n\nAdd logging, audit trails, or custom logic:\n\n```go\ntype MyHooks struct {\n    goauth.NoOpEventHooks // Embed to only override what you need\n}\n\nfunc (h *MyHooks) OnBeforeAuthenticate(ctx context.Context, strategy string, params goauth.AuthParams) error {\n    // Rate limiting, logging, etc.\n    // Return error to block authentication\n    return nil\n}\n\nfunc (h *MyHooks) OnAfterAuthenticate(ctx context.Context, strategy string, result *goauth.AuthResult, err error) {\n    if err != nil {\n        log.Printf(\"Auth failed for strategy %s: %v\", strategy, err)\n    } else {\n        log.Printf(\"User %s authenticated via %s\", result.Authenticatable.GetID(), strategy)\n    }\n}\n\nfunc (h *MyHooks) OnTokenIssued(ctx context.Context, auth goauth.Authenticatable, tokens *goauth.TokenPair) {\n    log.Printf(\"Tokens issued for user %s, session %s\", auth.GetID(), tokens.Access.SessionID)\n}\n\nfunc (h *MyHooks) OnTokenRevoked(ctx context.Context, auth goauth.Authenticatable, token string) {\n    log.Printf(\"Token revoked for user %s\", auth.GetID())\n}\n\n// Register hooks\nga.SetEventHooks(\u0026MyHooks{})\n```\n\n## Strategy Enhancements\n\n### LocalStrategy with Password Validation \u0026 Rate Limiting\n\n```go\nstrategy := goauth.NewLocalStrategy(lookupUser).\n    WithName(\"local\").\n    WithPasswordValidator(\n        func(plain, hashed string) bool {\n            return bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain)) == nil\n        },\n        func(user goauth.Authenticatable) string {\n            return user.(*MyUser).HashedPassword\n        },\n    ).\n    WithRateLimiter(\n        func(ctx context.Context, identifier string) error {\n            // Return goauth.ErrRateLimitExceeded if blocked\n            return nil\n        },\n        func(ctx context.Context, identifier string, success bool) {\n            // Record attempt for rate limiting\n        },\n    ).\n    WithUsernameNormalizer(func(username string) string {\n        return strings.ToLower(strings.TrimSpace(username))\n    })\n```\n\n### JWTStrategy with Token Type \u0026 Revocation Check\n\n```go\nstrategy := goauth.NewJWTStrategy(issuer).\n    WithName(\"jwt\").\n    WithExpectedType(goauth.AccessToken).  // Reject refresh tokens\n    WithRevocationCheck(func(ctx context.Context, token string) bool {\n        // Return true if token is revoked\n        return isRevoked(token)\n    })\n```\n\n## Signing Algorithms\n\n```go\n// HMAC (symmetric)\nkp, _ := goauth.NewHMACKeyProvider([]byte(\"secret\"), goauth.HS256)\nkp, _ := goauth.NewHMACKeyProvider([]byte(\"secret\"), goauth.HS384)\nkp, _ := goauth.NewHMACKeyProvider([]byte(\"secret\"), goauth.HS512)\n\n// RSA (asymmetric)\nkp, _ := goauth.NewRSAKeyProvider(privateKey, publicKey, goauth.RS256)\n\n// ECDSA (asymmetric)\nkp, _ := goauth.NewECDSAKeyProvider(privateKey, publicKey, goauth.ES256)\n```\n\n## HTTP Integration\n\n```go\nfunc loginHandler(w http.ResponseWriter, r *http.Request) {\n    var req LoginRequest\n    json.NewDecoder(r.Body).Decode(\u0026req)\n\n    _, pair, err := ga.AuthenticateAndIssueTokenPair(r.Context(), \"local\", goauth.AuthParams{\n        UsernameOrEmail: req.Username,\n        Password:        req.Password,\n    })\n    if err != nil {\n        resp := goauth.ErrorResponseForError(err)\n        w.WriteHeader(resp.Status)\n        json.NewEncoder(w).Encode(resp)\n        return\n    }\n\n    json.NewEncoder(w).Encode(map[string]any{\n        \"access_token\":  pair.Access.Value,\n        \"refresh_token\": pair.Refresh.Value,\n        \"expires_in\":    int(pair.Access.ExpiresIn.Seconds()),\n        \"session_id\":    pair.Access.SessionID,\n    })\n}\n\nfunc authMiddleware(next http.Handler) http.Handler {\n    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n        token := strings.TrimPrefix(r.Header.Get(\"Authorization\"), \"Bearer \")\n        result, err := ga.Authenticate(r.Context(), \"jwt\", goauth.AuthParams{Token: token})\n        if err != nil {\n            resp := goauth.ErrorResponseForError(err)\n            w.WriteHeader(resp.Status)\n            json.NewEncoder(w).Encode(resp)\n            return\n        }\n        ctx := context.WithValue(r.Context(), \"user\", result.Authenticatable)\n        next.ServeHTTP(w, r.WithContext(ctx))\n    })\n}\n```\n\n## Error Types \u0026 HTTP Mapping\n\n| Error Type        | HTTP Status | Error Code                                                                            |\n| ----------------- | ----------- | ------------------------------------------------------------------------------------- |\n| `CredentialError` | 401         | `invalid_credentials`                                                                 |\n| `TokenError`      | 401         | `token_error` / `token_missing` / `token_invalid` / `token_expired` / `token_revoked` |\n| `ValidationError` | 400         | `validation_error`                                                                    |\n| `RateLimitError`  | 429         | `rate_limit_exceeded`                                                                 |\n| `NotFoundError`   | 404         | `not_found` / `strategy_not_found` / `session_not_found`                              |\n| `ConfigError`     | 500         | `config_error`                                                                        |\n| `InternalError`   | 500         | `internal_error`                                                                      |\n| `SessionError`    | 401         | `session_error`                                                                       |\n\n```go\n// Get structured error response\nresp := goauth.ErrorResponseForError(err)\n// resp.Status, resp.Code, resp.Message, resp.Fields (for validation), resp.RetryAfter (for rate limit)\n\n// Or individual helpers\nstatus := goauth.HTTPStatusForError(err)\ncode := goauth.ErrorCodeForError(err)\nretryAfter := goauth.RetryAfterForError(err)\n```\n\n## Thread Safety\n\n`GoAuth` is safe for concurrent use:\n\n```go\n// Strategy registration is mutex-protected\nga.RegisterStrategy(strategy)\nga.UnregisterStrategy(\"oauth\")\nga.HasStrategy(\"local\")\nga.ListStrategies()\n```\n\n## Singleton Access\n\nFor convenience when DI isn't practical:\n\n```go\nga.RegisterSingleton()              // Overwrite allowed\n_ = ga.RegisterSingletonOnce()      // Set once, error on second\n\n// Later\nga = goauth.GetInstance()\n\n// Testing\nrestore := goauth.ReplaceSingletonForTest(mockGA)\ndefer restore()\n```\n\n## Examples\n\n```bash\n# Multi-session demo\ngo run ./example\n\n# HTTP server with login, refresh, logout, sessions endpoints\ngo run ./example/http_server\n```\n\nThe HTTP server example provides:\n\n- `POST /login` - Authenticate and get tokens\n- `POST /refresh` - Refresh tokens\n- `POST /logout` - Revoke current session\n- `POST /logout-all` - Revoke all sessions\n- `GET /me` - Get current user (protected)\n- `GET /sessions` - List active sessions (protected)\n\n## Migration from v1\n\n### Breaking Changes\n\n1. **Module path changed**: Import path is now `github.com/openframebox/goauth/v2`\n\n   ```go\n   // v1\n   import goauth \"github.com/openframebox/goauth\"\n   // v2\n   import goauth \"github.com/openframebox/goauth/v2\"\n   ```\n\n2. **TokenIssuer interface**: `CreateRefreshToken` signature changed\n\n   ```go\n   // v1\n   CreateRefreshToken(ctx, auth, refreshing bool) (*Token, error)\n   // v2\n   CreateRefreshToken(ctx, auth, oldToken *string) (*Token, error)\n   ```\n\n3. **StoreRefreshTokenFunc**: signature changed\n\n   ```go\n   // v1\n   func(ctx, auth, token, refreshing bool) error\n   // v2\n   func(ctx, auth, token, oldToken *string) error\n   ```\n\n4. **Strategy constructors**: use builder pattern\n\n   ```go\n   // v1\n   \u0026goauth.LocalStrategy{LookupUserWith: fn}\n   // v2\n   goauth.NewLocalStrategy(fn)\n\n   // v1\n   \u0026goauth.JWTStrategy{TokenIssuer: ti}\n   // v2\n   goauth.NewJWTStrategy(ti)\n   ```\n\n5. **Token struct**: new fields added\n\n   - `Type` (TokenType) - \"access\" or \"refresh\"\n   - `IssuedAt` (time.Time)\n   - `SessionID` (string)\n\n6. **New required method on TokenIssuer**: `RevokeRefreshToken(ctx, token string) error`\n\n7. **GoAuth methods**: New `TokenPair` returning methods added\n   - `IssueTokenPair()` alongside `IssueTokens()`\n   - `RefreshTokenPair()` alongside `RefreshToken()`\n   - `AuthenticateAndIssueTokenPair()` alongside `AuthenticateAndIssueTokens()`\n\n### New Features in v2\n\n- **Multi-session support** with `SessionTokenIssuer`\n- **Multiple signing algorithms** (HS256/384/512, RS256/384/512, ES256/384/512)\n- **Event hooks** (`AuthEventHooks` interface)\n- **Rate limiting support** in strategies\n- **Password validation** in `LocalStrategy`\n- **Token type validation** in `JWTStrategy`\n- **Thread-safe** strategy registration with `sync.RWMutex`\n- **New error types**: `RateLimitError`, `ValidationError`, `SessionError`\n- **Session management**: `ListSessions`, `RevokeSession`, `RevokeAllTokens`\n\n## Security Notes\n\n- Use strong, rotated secrets. Keep them out of source control.\n- Set correct `issuer` and `audience` claims.\n- Keep access tokens short-lived (5-15 min).\n- Implement proper refresh token rotation.\n- Revoke tokens on logout and suspicious activity.\n- Use rate limiting on authentication endpoints.\n- Hash passwords with bcrypt/argon2.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenframebox%2Fgoauth","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fopenframebox%2Fgoauth","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenframebox%2Fgoauth/lists"}