Skip to content

Commit 4fd57b5

Browse files
committed
feat: add support for Forgejo
1 parent c98b2f5 commit 4fd57b5

File tree

3 files changed

+155
-4
lines changed

3 files changed

+155
-4
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ to get those tokens in the right place.
99
## Features
1010

1111
- OAuth device flow authentication (no manual token creation needed)
12-
- Support for multiple providers (GitHub, GitHub Enterprise, GitLab, and Gitea)
12+
- Support for multiple providers (GitHub, GitHub Enterprise, GitLab, Gitea, and Forgejo)
1313
- Token storage in `~/.config/nix/nix.conf`
1414
- Token validation and status checking
1515
- Automatic backup creation before modifying configuration
@@ -85,6 +85,10 @@ nix-auth login gitlab --host gitlab.company.com --client-id <your-application-id
8585
# Gitea (uses Personal Access Token flow)
8686
nix-auth login gitea
8787
nix-auth login gitea --host gitea.company.com
88+
89+
# Forgejo (uses Personal Access Token flow)
90+
nix-auth login codeberg # for codeberg.org
91+
nix-auth login forgejo --host git.company.com # for self-hosted Forgejo (--host required)
8892
```
8993

9094
The tool will:
@@ -96,7 +100,7 @@ The tool will:
96100
**Note for self-hosted instances**:
97101
- **GitHub Enterprise**: You'll need to create an OAuth App and provide the client ID via `--client-id`
98102
- **GitLab self-hosted**: You'll need to create an OAuth application and provide the client ID via `--client-id`
99-
- **Gitea**: Uses Personal Access Token flow instead of OAuth device flow (Gitea doesn't support device flow yet)
103+
- **Gitea/Forgejo**: Uses Personal Access Token flow instead of OAuth device flow (these platforms don't support device flow yet)
100104

101105
The tool will guide you through this process if the client ID is not provided. You can also set the `GITHUB_CLIENT_ID` or `GITLAB_CLIENT_ID` environment variables as an alternative to the `--client-id` flag.
102106

@@ -146,7 +150,7 @@ access-tokens = github.com=ghp_xxxxxxxxxxxxxxxxxxxx gitlab.com=glpat-xxxxxxxxxxx
146150

147151
## Future Plans
148152

149-
- Support for more providers (Forgejo, Bitbucket, etc.)
153+
- Support for more providers (Bitbucket, etc.)
150154
- Token expiration notifications
151155
- Integration with system keychains for secure storage (will require patching
152156
Nix)

cmd/login.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import (
1515
var loginCmd = &cobra.Command{
1616
Use: "login [provider]",
1717
Short: "Authenticate with a provider and save the access token",
18-
Long: `Authenticate with a provider (GitHub, GitLab, Gitea, etc.) using OAuth device flow
18+
Long: `Authenticate with a provider (GitHub, GitLab, Gitea, Forgejo, etc.) using OAuth device flow
1919
and save the access token to your nix.conf for use with Nix flakes.`,
2020
Example: ` nix-auth login # defaults to GitHub
2121
nix-auth login github
2222
nix-auth login gitlab
2323
nix-auth login gitea
24+
nix-auth login codeberg # for codeberg.org
25+
nix-auth login forgejo --host git.company.com # --host required for forgejo
2426
nix-auth login github --host github.company.com --client-id abc123
2527
nix-auth login gitea --host gitea.company.com`,
2628
Args: cobra.MaximumNArgs(1),

internal/provider/forgejo.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
10+
"github.com/cli/browser"
11+
)
12+
13+
func init() {
14+
Register("forgejo", &ForgejoProvider{})
15+
Register("codeberg", &ForgejoProvider{host: "codeberg.org"})
16+
}
17+
18+
type ForgejoProvider struct {
19+
host string
20+
}
21+
22+
func (f *ForgejoProvider) SetHost(host string) {
23+
f.host = host
24+
}
25+
26+
func (f *ForgejoProvider) SetClientID(clientID string) {
27+
}
28+
29+
func (f *ForgejoProvider) getBaseURL() string {
30+
if f.host != "" {
31+
return fmt.Sprintf("https://%s", f.host)
32+
}
33+
// This should not happen as we validate in Authenticate()
34+
return ""
35+
}
36+
37+
func (f *ForgejoProvider) getAPIURL() string {
38+
return fmt.Sprintf("%s/api/v1", f.getBaseURL())
39+
}
40+
41+
func (f *ForgejoProvider) makeForgejoAPIRequest(ctx context.Context, token string, endpoint string) (*http.Response, error) {
42+
headers := map[string]string{
43+
"Accept": "application/json",
44+
}
45+
return makeAuthenticatedRequest(ctx, "GET", endpoint, "token "+token, headers)
46+
}
47+
48+
func (f *ForgejoProvider) Name() string {
49+
return "forgejo"
50+
}
51+
52+
func (f *ForgejoProvider) Host() string {
53+
return f.host
54+
}
55+
56+
func (f *ForgejoProvider) GetScopes() []string {
57+
return []string{"read:repository", "read:user"}
58+
}
59+
60+
func (f *ForgejoProvider) Authenticate(ctx context.Context) (string, error) {
61+
// Validate that we have a host
62+
if f.host == "" {
63+
return "", fmt.Errorf("--host flag is required for forgejo provider (e.g., --host git.company.com)")
64+
}
65+
66+
fmt.Println()
67+
fmt.Println("Forgejo does not support OAuth device flow. You'll need to create a Personal Access Token.")
68+
fmt.Println()
69+
fmt.Println("Instructions:")
70+
fmt.Printf("1. Go to %s/user/settings/applications\n", f.getBaseURL())
71+
fmt.Println("2. In the 'Generate New Token' section, enter a token name (e.g., 'nix-auth')")
72+
fmt.Println("3. Select the following access and permissions:")
73+
fmt.Println(" - Repository and Organization Access: All (public, private, and limited)")
74+
fmt.Println(" - Permissions: read:repository, read:user")
75+
fmt.Println("4. Click 'Generate Token'")
76+
fmt.Println("5. Copy the generated token")
77+
fmt.Println()
78+
79+
tokenURL := fmt.Sprintf("%s/user/settings/applications", f.getBaseURL())
80+
fmt.Printf("Opening %s in your browser...\n", tokenURL)
81+
82+
if err := browser.OpenURL(tokenURL); err != nil {
83+
fmt.Println("Could not open browser automatically.")
84+
fmt.Printf("Please manually visit: %s\n", tokenURL)
85+
}
86+
87+
fmt.Println()
88+
var token string
89+
fmt.Print("Enter your Personal Access Token: ")
90+
if _, err := fmt.Scanln(&token); err != nil {
91+
return "", fmt.Errorf("failed to read token: %w", err)
92+
}
93+
94+
token = strings.TrimSpace(token)
95+
if token == "" {
96+
return "", fmt.Errorf("token cannot be empty")
97+
}
98+
99+
if err := f.ValidateToken(ctx, token); err != nil {
100+
return "", fmt.Errorf("invalid token: %w", err)
101+
}
102+
103+
return token, nil
104+
}
105+
106+
func (f *ForgejoProvider) ValidateToken(ctx context.Context, token string) error {
107+
userURL := fmt.Sprintf("%s/user", f.getAPIURL())
108+
resp, err := f.makeForgejoAPIRequest(ctx, token, userURL)
109+
if err != nil {
110+
return fmt.Errorf("failed to validate token: %w", err)
111+
}
112+
defer resp.Body.Close()
113+
114+
return nil
115+
}
116+
117+
func (f *ForgejoProvider) GetUserInfo(ctx context.Context, token string) (username, fullName string, err error) {
118+
userURL := fmt.Sprintf("%s/user", f.getAPIURL())
119+
resp, err := f.makeForgejoAPIRequest(ctx, token, userURL)
120+
if err != nil {
121+
return "", "", fmt.Errorf("failed to get user info: %w", err)
122+
}
123+
defer resp.Body.Close()
124+
125+
var user struct {
126+
Login string `json:"login"`
127+
Username string `json:"username"`
128+
FullName string `json:"full_name"`
129+
}
130+
131+
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
132+
return "", "", fmt.Errorf("failed to decode response: %w", err)
133+
}
134+
135+
username = user.Username
136+
if username == "" {
137+
username = user.Login
138+
}
139+
140+
return username, user.FullName, nil
141+
}
142+
143+
func (f *ForgejoProvider) GetTokenScopes(ctx context.Context, token string) ([]string, error) {
144+
return f.GetScopes(), nil
145+
}

0 commit comments

Comments
 (0)