Skip to content

Commit 4a25937

Browse files
committed
Allow cozypass to login with OIDC
1 parent a1d3775 commit 4a25937

4 files changed

Lines changed: 193 additions & 20 deletions

File tree

docs/delegated-auth.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,68 @@ Set-Cookie: ...
177177
Location: https://name00001-home.mycozy.cloud/
178178
```
179179

180+
#### GET /oidc/bitwarden/:context
181+
182+
This route can be used by a bitwarden client to get a token from the OpenID
183+
Connect Identity Provider, and the fqdn of the associated cozy instance. This
184+
token can then be exchanged for credentials for the cozy instance.
185+
186+
```http
187+
GET /oidc/bitwarden/examplecontext?redirect_uri=cozypass://login HTTP/1.1
188+
Host: oauthcallback.mycozy.cloud
189+
```
190+
191+
```http
192+
HTTP/1.1 303 See Other
193+
Location: https://identity-provider/path/to/authorize?response_type=code&state=9f6873dfce7d&scope=openid+profile+email&client_id=aClientID&nonce=94246498&redirect_uri=https://oauthcallback.mycozy.cloud/oidc/redirect
194+
```
195+
196+
[...]
197+
198+
```http
199+
HTTP/1.1 303 See Other
200+
Location: cozypass://login?code=xxx&instance=alice.example.com
201+
```
202+
203+
#### POST /oidc/bitwarden/:context
204+
205+
This route can be used by a bitwarden client to exchange the token from the
206+
previous route for credentials.
207+
208+
```http
209+
POST /oidc/bitwarden/examplecontext HTTP/1.1
210+
Host: alice.example.com
211+
Content-Type: application/x-www-form-urlencoded
212+
```
213+
214+
```
215+
code=xxx&
216+
client_id=mobile&
217+
deviceType=0&
218+
deviceIdentifier=aac2e34a-44db-42ab-a733-5322dd582c3d&
219+
deviceName=android&
220+
clientName=CozyPass&
221+
devicePushToken=
222+
```
223+
224+
```http
225+
HTTP/1.1 200 OK
226+
Content-Type: application/json
227+
```
228+
229+
```json
230+
{
231+
"client_id": "f05671e159450b44d5c78cebbd0260b5",
232+
"registration_access_token": "J9l-ZhwP[...omitted for brevity...]",
233+
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)",
234+
"expires_in": 3600,
235+
"token_type": "Bearer",
236+
"refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf",
237+
"Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=",
238+
"PrivateKey": null
239+
}
240+
```
241+
180242
#### POST /oidc/:context/logout
181243

182244
This route implements the OpenID Connect Back-Channel Logout. It means that the

web/bitwarden/bitwarden.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,6 @@ type AccessTokenReponse struct {
270270

271271
func getInitialCredentials(c echo.Context) error {
272272
inst := middlewares.GetInstance(c)
273-
log := inst.Logger().WithNamespace("bitwarden")
274273
pass := []byte(c.FormValue("password"))
275274

276275
// Authentication
@@ -286,6 +285,12 @@ func getInitialCredentials(c echo.Context) error {
286285
}
287286
}
288287

288+
return RegisterClientAndReturnTokens(c, inst)
289+
}
290+
291+
func RegisterClientAndReturnTokens(c echo.Context, inst *instance.Instance) error {
292+
log := inst.Logger().WithNamespace("bitwarden")
293+
289294
// Register the client
290295
kind := bitwarden.ParseBitwardenDeviceType(c.FormValue("deviceType"))
291296
clientName := c.FormValue("clientName")

web/oidc/oidc.go

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/cozy/cozy-stack/pkg/logger"
2929
"github.com/cozy/cozy-stack/pkg/prefixer"
3030
"github.com/cozy/cozy-stack/web/auth"
31+
"github.com/cozy/cozy-stack/web/bitwarden"
3132
"github.com/cozy/cozy-stack/web/middlewares"
3233
"github.com/cozy/cozy-stack/web/statik"
3334
jwt "github.com/golang-jwt/jwt/v5"
@@ -50,7 +51,7 @@ func Start(c echo.Context) error {
5051
inst.Logger().WithNamespace("oidc").Infof("Start error: %s", err)
5152
return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
5253
}
53-
u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), conf)
54+
u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), "", conf)
5455
if err != nil {
5556
return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
5657
}
@@ -65,13 +66,78 @@ func StartFranceConnect(c echo.Context) error {
6566
inst.Logger().WithNamespace("oidc").Infof("StartFranceConnect error: %s", err)
6667
return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
6768
}
68-
u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), conf)
69+
u, err := makeStartURL(inst.Domain, c.QueryParam("redirect"), c.QueryParam("confirm_state"), "", conf)
6970
if err != nil {
7071
return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
7172
}
7273
return c.Redirect(http.StatusSeeOther, u)
7374
}
7475

76+
func checkRedirectURIForBitwarden(redirectURI string) error {
77+
if redirectURI == "" {
78+
return errors.New("redirect URI is required")
79+
}
80+
u, err := url.Parse(redirectURI)
81+
if err != nil {
82+
return errors.New("invalid redirect URI")
83+
}
84+
if u.Scheme == "cozypass" || u.Scheme == "moz-extension" || u.Scheme == "chrome-extension" {
85+
return nil
86+
}
87+
if u.Scheme == "https" && u.Host == "links.mycozy.cloud" {
88+
return nil
89+
}
90+
return errors.New("invalid redirect URI")
91+
}
92+
93+
// BitwardenStart starts the OIDC flow for Bitwarden clients
94+
func BitwardenStart(c echo.Context) error {
95+
contextName := c.Param("context")
96+
redirectURI := c.QueryParam("redirect_uri")
97+
98+
if err := checkRedirectURIForBitwarden(redirectURI); err != nil {
99+
return renderError(c, nil, http.StatusNotFound, "Sorry, the redirect URI is not valid.")
100+
}
101+
102+
conf, err := getGenericConfig(contextName)
103+
if err != nil {
104+
return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
105+
}
106+
u, err := makeStartURL("", redirectURI, "", contextName, conf)
107+
if err != nil {
108+
return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
109+
}
110+
return c.Redirect(http.StatusSeeOther, u)
111+
}
112+
113+
// BitwardenExchange handles the POST /oidc/bitwarden/:context route for exchanging
114+
// a delegated code for bitwarden credentials
115+
func BitwardenExchange(c echo.Context) error {
116+
inst := middlewares.GetInstance(c)
117+
118+
code := c.FormValue("code")
119+
if code == "" {
120+
return c.JSON(http.StatusBadRequest, echo.Map{
121+
"error": "code parameter is required",
122+
})
123+
}
124+
125+
sub := getStorage().GetSub(code)
126+
invalidCode := sub == ""
127+
if sub != inst.OIDCID && sub != inst.Domain {
128+
invalidCode = true
129+
}
130+
if invalidCode {
131+
inst.Logger().WithNamespace("oidc").Infof("BitwardenExchange invalid code: %s (%s - %s)",
132+
sub, inst.OIDCID, inst.Domain)
133+
return c.JSON(http.StatusBadRequest, echo.Map{
134+
"error": "invalid code",
135+
})
136+
}
137+
138+
return bitwarden.RegisterClientAndReturnTokens(c, inst)
139+
}
140+
75141
// Redirect is the route after the Identity Provider has redirected the user to
76142
// the stack. The redirection is made to a generic domain, like
77143
// oauthcallback.cozy.localhost and the association with an instance is made via a
@@ -85,6 +151,42 @@ func Redirect(c echo.Context) error {
85151
return renderError(c, nil, http.StatusNotFound, "Sorry, the session has expired.")
86152
}
87153

154+
if state.BitwardenContext != "" {
155+
conf, err := getGenericConfig(state.BitwardenContext)
156+
if err != nil {
157+
return renderError(c, nil, http.StatusBadRequest, "No OpenID Connect is configured.")
158+
}
159+
accessToken, err := getToken(conf, c.QueryParam("code"))
160+
if err != nil {
161+
logger.WithNamespace("oidc").Errorf("Error on getToken: %s", err)
162+
return renderError(c, nil, http.StatusBadGateway, "Error from the identity provider.")
163+
}
164+
params, err := getUserInfo(conf, accessToken)
165+
if err != nil {
166+
return err
167+
}
168+
sub, ok := params["sub"].(string)
169+
if !ok {
170+
logger.WithNamespace("oidc").Errorf("Missing sub")
171+
return ErrAuthenticationFailed
172+
}
173+
domain, err := extractDomain(conf, params)
174+
if err != nil {
175+
return renderError(c, nil, http.StatusNotFound, "Sorry, the cozy was not found.")
176+
}
177+
178+
code := getStorage().CreateCode(sub)
179+
u, err := url.Parse(state.Redirect)
180+
if err != nil {
181+
return renderError(c, nil, http.StatusNotFound, "Sorry, an error occurred.")
182+
}
183+
q := u.Query()
184+
q.Add("code", code)
185+
q.Add("instance", domain)
186+
u.RawQuery = q.Encode()
187+
return c.Redirect(http.StatusSeeOther, u.String())
188+
}
189+
88190
domain := state.Instance
89191
if contextName, ok := FindLoginDomain(domain); ok {
90192
conf, err := getGenericConfig(contextName)
@@ -625,12 +727,12 @@ func getFranceConnectConfig(context string) (*Config, error) {
625727
return config, nil
626728
}
627729

628-
func makeStartURL(domain, redirect, confirm string, conf *Config) (string, error) {
730+
func makeStartURL(domain, redirect, confirm, bitwardenContext string, conf *Config) (string, error) {
629731
u, err := url.Parse(conf.AuthorizeURL)
630732
if err != nil {
631733
return "", err
632734
}
633-
state := newStateHolder(domain, redirect, confirm, conf.Provider)
735+
state := newStateHolder(domain, redirect, confirm, bitwardenContext, conf.Provider)
634736
if err = getStorage().Add(state); err != nil {
635737
return "", err
636738
}
@@ -948,6 +1050,8 @@ func renderError(c echo.Context, inst *instance.Instance, code int, msg string)
9481050
func Routes(router *echo.Group) {
9491051
router.GET("/start", Start, middlewares.NeedInstance, middlewares.CheckOnboardingNotFinished)
9501052
router.GET("/franceconnect", StartFranceConnect, middlewares.NeedInstance, middlewares.CheckOnboardingNotFinished)
1053+
router.GET("/bitwarden/:context", BitwardenStart)
1054+
router.POST("/bitwarden/:context", BitwardenExchange, middlewares.NeedInstance)
9511055
router.GET("/redirect", Redirect)
9521056
router.GET("/login", Login, middlewares.NeedInstance)
9531057
router.POST("/twofactor", TwoFactor, middlewares.NeedInstance)
@@ -1043,7 +1147,7 @@ func LoginDomainHandler(c echo.Context, contextName string) error {
10431147
if err != nil {
10441148
return renderError(c, nil, http.StatusNotFound, "Sorry, the context was not found.")
10451149
}
1046-
u, err := makeStartURL(r.Host, "", "", conf)
1150+
u, err := makeStartURL(r.Host, "", "", "", conf)
10471151
if err != nil {
10481152
return renderError(c, nil, http.StatusNotFound, "Sorry, the server is not configured for OpenID Connect.")
10491153
}

web/oidc/statestore.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ const (
1919
)
2020

2121
type stateHolder struct {
22-
id string
23-
expiresAt int64
24-
Provider ProviderOIDC
25-
Instance string
26-
Redirect string
27-
Nonce string
28-
Confirm string
22+
id string
23+
expiresAt int64
24+
Provider ProviderOIDC
25+
Instance string
26+
Redirect string
27+
Nonce string
28+
Confirm string
29+
BitwardenContext string
2930
}
3031

3132
type ProviderOIDC int
@@ -35,16 +36,17 @@ const (
3536
FranceConnectProvider
3637
)
3738

38-
func newStateHolder(domain, redirect, confirm string, provider ProviderOIDC) *stateHolder {
39+
func newStateHolder(domain, redirect, confirm, bitwardenContext string, provider ProviderOIDC) *stateHolder {
3940
id := hex.EncodeToString(crypto.GenerateRandomBytes(24))
4041
nonce := hex.EncodeToString(crypto.GenerateRandomBytes(24))
4142
return &stateHolder{
42-
id: id,
43-
Provider: provider,
44-
Instance: domain,
45-
Redirect: redirect,
46-
Confirm: confirm,
47-
Nonce: nonce,
43+
id: id,
44+
Provider: provider,
45+
Instance: domain,
46+
Redirect: redirect,
47+
Confirm: confirm,
48+
Nonce: nonce,
49+
BitwardenContext: bitwardenContext,
4850
}
4951
}
5052

0 commit comments

Comments
 (0)