Skip to content

Commit 60bef37

Browse files
author
gluck
committed
login: figaro login rou2, driven by rou2's discovery document
rou2 serves /.well-known/rou2 unauthenticated: issuer, client id, scopes. The login reads it, then the issuer's own OpenID discovery for the device and token endpoints, runs the device flow and hands the pair to hush under the default refresh_token grant. Nothing about Authelia is compiled into figaro. The discovery document arrives over plain http on the tailnet, so the issuer must be https, must match what its OpenID document claims, and both endpoints must sit on its origin; a tampered document cannot send the device code or the refresh token elsewhere. offline_access is always requested once, or Authelia mints no refresh token. No PKCE: Authelia's device flow does not enforce it, as kcal, a require_pkce device client, has shown for months. The provider-file merge that azure's login wrote inline is now mergeProviderFile, shared by both logins.
1 parent 487f54c commit 60bef37

5 files changed

Lines changed: 377 additions & 7 deletions

File tree

‎internal/auth/rou2.go‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"strings"
11+
"time"
12+
13+
"github.com/jack-work/figaro/internal/term"
14+
)
15+
16+
const Rou2WellKnown = "/.well-known/rou2"
17+
18+
type Rou2Upstream struct {
19+
Name string `json:"name"`
20+
BaseURL string `json:"base_url"`
21+
Default bool `json:"default"`
22+
}
23+
24+
type Rou2Discovery struct {
25+
Service string `json:"service"`
26+
Gate string `json:"gate"`
27+
Issuer string `json:"issuer"`
28+
ClientID string `json:"client_id"`
29+
Scopes []string `json:"scopes"`
30+
OIDCDiscovery string `json:"oidc_discovery"`
31+
Upstreams []Rou2Upstream `json:"upstreams"`
32+
}
33+
34+
type oidcConfiguration struct {
35+
Issuer string `json:"issuer"`
36+
TokenEndpoint string `json:"token_endpoint"`
37+
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
38+
}
39+
40+
var rou2HTTP = &http.Client{Timeout: 15 * time.Second}
41+
42+
func Rou2Root(raw string) (string, error) {
43+
root := strings.TrimRight(strings.TrimSpace(raw), "/")
44+
root = strings.TrimSuffix(root, "/v1")
45+
u, err := url.Parse(root)
46+
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") {
47+
return "", fmt.Errorf("rou2: %q is not a rou2 address, e.g. http://100.103.40.20:9110", raw)
48+
}
49+
return u.Scheme + "://" + u.Host, nil
50+
}
51+
52+
func DiscoverRou2(root string) (Rou2Discovery, DeviceCodeConfig, error) {
53+
var d Rou2Discovery
54+
if err := getJSON(root+Rou2WellKnown, &d); err != nil {
55+
return d, DeviceCodeConfig{}, fmt.Errorf("rou2 discovery: %w", err)
56+
}
57+
if d.Service != "rou2" {
58+
return d, DeviceCodeConfig{}, fmt.Errorf("rou2 discovery: %s%s does not describe a rou2 (service %q)", root, Rou2WellKnown, d.Service)
59+
}
60+
if d.Gate != "oidc" {
61+
return d, DeviceCodeConfig{}, fmt.Errorf("rou2: gate is %q, so there is nothing to log in to", d.Gate)
62+
}
63+
cfg, err := d.DeviceConfig()
64+
return d, cfg, err
65+
}
66+
67+
func (d Rou2Discovery) DeviceConfig() (DeviceCodeConfig, error) {
68+
issuer := strings.TrimRight(d.Issuer, "/")
69+
iu, err := url.Parse(issuer)
70+
if err != nil || iu.Scheme != "https" || iu.Host == "" {
71+
return DeviceCodeConfig{}, fmt.Errorf("rou2: issuer %q is not an https URL", d.Issuer)
72+
}
73+
if d.ClientID == "" {
74+
return DeviceCodeConfig{}, fmt.Errorf("rou2: discovery names no client_id")
75+
}
76+
disc := d.OIDCDiscovery
77+
if disc == "" {
78+
disc = issuer + "/.well-known/openid-configuration"
79+
}
80+
if !sameOrigin(disc, iu) {
81+
return DeviceCodeConfig{}, fmt.Errorf("rou2: oidc_discovery %q is not on the issuer %s", disc, issuer)
82+
}
83+
var oc oidcConfiguration
84+
if err := getJSON(disc, &oc); err != nil {
85+
return DeviceCodeConfig{}, fmt.Errorf("oidc discovery: %w", err)
86+
}
87+
if strings.TrimRight(oc.Issuer, "/") != issuer {
88+
return DeviceCodeConfig{}, fmt.Errorf("rou2: %s names issuer %q, rou2 names %q", disc, oc.Issuer, d.Issuer)
89+
}
90+
if oc.DeviceAuthorizationEndpoint == "" {
91+
return DeviceCodeConfig{}, fmt.Errorf("rou2: %s has no device_authorization_endpoint", issuer)
92+
}
93+
for _, e := range []string{oc.DeviceAuthorizationEndpoint, oc.TokenEndpoint} {
94+
if !sameOrigin(e, iu) {
95+
return DeviceCodeConfig{}, fmt.Errorf("rou2: endpoint %q is not on the issuer %s", e, issuer)
96+
}
97+
}
98+
return DeviceCodeConfig{
99+
ProviderName: "rou2",
100+
ClientID: d.ClientID,
101+
Scope: rou2Scope(d.Scopes),
102+
DeviceURL: oc.DeviceAuthorizationEndpoint,
103+
TokenURL: oc.TokenEndpoint,
104+
}, nil
105+
}
106+
107+
func rou2Scope(scopes []string) string {
108+
var parts []string
109+
for _, s := range scopes {
110+
if s = strings.TrimSpace(s); s != "" && s != offlineAccess {
111+
parts = append(parts, s)
112+
}
113+
}
114+
return strings.Join(append(parts, offlineAccess), " ")
115+
}
116+
117+
func sameOrigin(raw string, want *url.URL) bool {
118+
u, err := url.Parse(raw)
119+
return err == nil && u.Scheme == want.Scheme && u.Host == want.Host
120+
}
121+
122+
func getJSON(endpoint string, out any) error {
123+
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
124+
if err != nil {
125+
return err
126+
}
127+
req.Header.Set("Accept", "application/json")
128+
resp, err := rou2HTTP.Do(req)
129+
if err != nil {
130+
return err
131+
}
132+
defer resp.Body.Close()
133+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
134+
if err != nil {
135+
return err
136+
}
137+
if resp.StatusCode != http.StatusOK {
138+
return fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body[:min(len(body), 256)])))
139+
}
140+
if err := json.Unmarshal(body, out); err != nil {
141+
return fmt.Errorf("GET %s: %w", endpoint, err)
142+
}
143+
return nil
144+
}
145+
146+
func LoginRou2(cfg DeviceCodeConfig, issuer string) (DeviceTokens, error) {
147+
fmt.Fprintln(os.Stderr)
148+
fmt.Fprintln(os.Stderr, " "+term.Dim("rou2 signs in with "+issuer+" (device flow)."))
149+
fmt.Fprintln(os.Stderr)
150+
tok, err := LoginDeviceCode(cfg, printDevicePrompt)
151+
if err != nil {
152+
return DeviceTokens{}, err
153+
}
154+
if tok.RefreshToken == "" {
155+
return DeviceTokens{}, fmt.Errorf("rou2: %s issued no refresh token, so the login would end with this access token", issuer)
156+
}
157+
fmt.Fprintln(os.Stderr, " "+term.Green("\u2713")+" Authorized by "+issuer)
158+
return tok, nil
159+
}

‎internal/auth/rou2_test.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRou2Root(t *testing.T) {
12+
for in, want := range map[string]string{
13+
"http://100.103.40.20:9110": "http://100.103.40.20:9110",
14+
"http://100.103.40.20:9110/": "http://100.103.40.20:9110",
15+
" http://100.103.40.20:9110/v1": "http://100.103.40.20:9110",
16+
"https://rou2.example/v1/": "https://rou2.example",
17+
} {
18+
got, err := Rou2Root(in)
19+
if err != nil || got != want {
20+
t.Errorf("Rou2Root(%q) = %q, %v; want %q", in, got, err, want)
21+
}
22+
}
23+
for _, bad := range []string{"", "100.103.40.20:9110", "ftp://x", "http://x/u/explabs/v1"} {
24+
if _, err := Rou2Root(bad); err == nil {
25+
t.Errorf("Rou2Root(%q) accepted", bad)
26+
}
27+
}
28+
}
29+
30+
type rou2Fixture struct {
31+
issuer *httptest.Server
32+
rou2 *httptest.Server
33+
oidc map[string]string
34+
doc map[string]any
35+
}
36+
37+
func newRou2Fixture(t *testing.T) *rou2Fixture {
38+
f := &rou2Fixture{}
39+
f.issuer = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40+
if r.URL.Path != "/.well-known/openid-configuration" {
41+
http.NotFound(w, r)
42+
return
43+
}
44+
json.NewEncoder(w).Encode(f.oidc)
45+
}))
46+
t.Cleanup(f.issuer.Close)
47+
f.rou2 = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
48+
if r.URL.Path != Rou2WellKnown {
49+
http.NotFound(w, r)
50+
return
51+
}
52+
json.NewEncoder(w).Encode(f.doc)
53+
}))
54+
t.Cleanup(f.rou2.Close)
55+
f.oidc = map[string]string{
56+
"issuer": f.issuer.URL,
57+
"token_endpoint": f.issuer.URL + "/api/oidc/token",
58+
"device_authorization_endpoint": f.issuer.URL + "/api/oidc/device-authorization",
59+
}
60+
f.doc = map[string]any{
61+
"service": "rou2",
62+
"gate": "oidc",
63+
"issuer": f.issuer.URL,
64+
"client_id": "rou2",
65+
"scopes": []string{"openid", "profile", "offline_access", "groups"},
66+
"oidc_discovery": f.issuer.URL + "/.well-known/openid-configuration",
67+
"upstreams": []map[string]any{{"name": "explabs", "base_url": f.rou2.URL + "/u/explabs/v1", "default": true}},
68+
}
69+
prev := rou2HTTP
70+
rou2HTTP = f.issuer.Client()
71+
t.Cleanup(func() { rou2HTTP = prev })
72+
return f
73+
}
74+
75+
func TestDiscoverRou2BuildsTheDeviceFlowFromBothDocuments(t *testing.T) {
76+
f := newRou2Fixture(t)
77+
d, cfg, err := DiscoverRou2(f.rou2.URL)
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
if cfg.ClientID != "rou2" || cfg.TokenURL != f.oidc["token_endpoint"] || cfg.DeviceURL != f.oidc["device_authorization_endpoint"] {
82+
t.Fatalf("cfg = %+v", cfg)
83+
}
84+
// offline_access must appear exactly once and last, or Authelia mints no refresh token.
85+
if cfg.Scope != "openid profile groups offline_access" {
86+
t.Fatalf("scope = %q", cfg.Scope)
87+
}
88+
if len(d.Upstreams) != 1 || d.Upstreams[0].Name != "explabs" {
89+
t.Fatalf("upstreams = %+v", d.Upstreams)
90+
}
91+
}
92+
93+
func TestDiscoverRou2RefusesEndpointsOffTheIssuer(t *testing.T) {
94+
// A tampered discovery document must not send the device code or the
95+
// refresh token to a host that is not the issuer.
96+
f := newRou2Fixture(t)
97+
f.oidc["token_endpoint"] = "https://evil.example/token"
98+
if _, _, err := DiscoverRou2(f.rou2.URL); err == nil || !strings.Contains(err.Error(), "not on the issuer") {
99+
t.Fatalf("err = %v", err)
100+
}
101+
}
102+
103+
func TestDiscoverRou2RefusesAnIssuerMismatch(t *testing.T) {
104+
f := newRou2Fixture(t)
105+
f.oidc["issuer"] = "https://other.example"
106+
if _, _, err := DiscoverRou2(f.rou2.URL); err == nil || !strings.Contains(err.Error(), "names issuer") {
107+
t.Fatalf("err = %v", err)
108+
}
109+
}
110+
111+
func TestDiscoverRou2RefusesAPlainHTTPIssuer(t *testing.T) {
112+
f := newRou2Fixture(t)
113+
f.doc["issuer"] = strings.Replace(f.issuer.URL, "https://", "http://", 1)
114+
if _, _, err := DiscoverRou2(f.rou2.URL); err == nil || !strings.Contains(err.Error(), "https") {
115+
t.Fatalf("err = %v", err)
116+
}
117+
}
118+
119+
func TestDiscoverRou2RefusesAnOpenGate(t *testing.T) {
120+
f := newRou2Fixture(t)
121+
f.doc["gate"] = "open"
122+
if _, _, err := DiscoverRou2(f.rou2.URL); err == nil {
123+
t.Fatal("an open gate was treated as a login target")
124+
}
125+
}

‎internal/cli/azure_login.go‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,21 +110,26 @@ func azureEndpoint(raw string) (string, error) {
110110
}
111111

112112
func writeAzureSettings(loaded *config.Loaded, base string, s auth.AzureSettings) error {
113-
path := loaded.ProviderAuthPath(azureProvider)
113+
return mergeProviderFile(loaded, azureProvider, map[string]string{
114+
"base_url": base,
115+
"tenant": s.Tenant,
116+
"client_id": s.ClientID,
117+
"scope": s.Scope,
118+
"authority": s.Authority,
119+
})
120+
}
121+
122+
func mergeProviderFile(loaded *config.Loaded, provider string, kv map[string]string) error {
123+
path := loaded.ProviderAuthPath(provider)
114124
doc := map[string]any{}
115125
if _, err := toml.DecodeFile(path, &doc); err != nil && !os.IsNotExist(err) {
116126
return fmt.Errorf("%s: %w", path, err)
117127
}
118-
set := func(k, v string) {
128+
for k, v := range kv {
119129
if v = strings.TrimSpace(v); v != "" {
120130
doc[k] = v
121131
}
122132
}
123-
set("base_url", base)
124-
set("tenant", s.Tenant)
125-
set("client_id", s.ClientID)
126-
set("scope", s.Scope)
127-
set("authority", s.Authority)
128133
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
129134
return err
130135
}

‎internal/cli/firstrun.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func init() {
4747
r.Setup = runAzureLogin
4848
r.Login = runAzureLogin
4949
}
50+
if r := providerPkg.Lookup(rou2Provider); r != nil {
51+
r.Setup = runRou2Login
52+
r.Login = runRou2Login
53+
}
5054
if r := providerPkg.Lookup("anthropic"); r != nil {
5155
login := func(loaded *config.Loaded) error {
5256
return runOAuthInline("anthropic", auth.AnthropicOAuth)

0 commit comments

Comments
 (0)