Skip to content

Commit 19fa030

Browse files
committed
Implement refresh grant
1 parent de037d3 commit 19fa030

File tree

8 files changed

+350
-44
lines changed

8 files changed

+350
-44
lines changed

coderd/apidoc/docs.go

Lines changed: 14 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 13 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codersdk/oauth2.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,12 @@ type OAuth2ProviderGrantType string
184184

185185
const (
186186
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
187+
OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token"
187188
)
188189

189190
func (e OAuth2ProviderGrantType) Valid() bool {
190-
//nolint:gocritic,revive // More cases will be added later.
191191
switch e {
192-
case OAuth2ProviderGrantTypeAuthorizationCode:
192+
case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken:
193193
return true
194194
}
195195
return false

docs/api/enterprise.md

Lines changed: 10 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

enterprise/coderd/identityprovider/tokens.go

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ var errBadSecret = xerrors.New("Invalid client secret")
3030
// errBadCode means the user provided a bad code.
3131
var errBadCode = xerrors.New("Invalid code")
3232

33+
// errBadToken means the user provided a bad token.
34+
var errBadToken = xerrors.New("Invalid token")
35+
3336
type tokenParams struct {
3437
clientID string
3538
clientSecret string
3639
code string
3740
grantType codersdk.OAuth2ProviderGrantType
3841
redirectURL *url.URL
42+
refreshToken string
3943
}
4044

4145
func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) {
@@ -44,15 +48,24 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
4448
if err != nil {
4549
return tokenParams{}, nil, xerrors.Errorf("parse form: %w", err)
4650
}
47-
p.RequiredNotEmpty("grant_type", "client_secret", "client_id", "code")
4851

4952
vals := r.Form
53+
p.RequiredNotEmpty("grant_type")
54+
grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType])
55+
switch grantType {
56+
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
57+
p.RequiredNotEmpty("refresh_token")
58+
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
59+
p.RequiredNotEmpty("client_secret", "client_id", "code")
60+
}
61+
5062
params := tokenParams{
5163
clientID: p.String(vals, "", "client_id"),
5264
clientSecret: p.String(vals, "", "client_secret"),
5365
code: p.String(vals, "", "code"),
66+
grantType: grantType,
5467
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
55-
grantType: httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType]),
68+
refreshToken: p.String(vals, "", "refresh_token"),
5669
}
5770

5871
p.ErrorExcessParams(vals)
@@ -89,7 +102,9 @@ func Tokens(db database.Store, defaultLifetime time.Duration) http.HandlerFunc {
89102
var token oauth2.Token
90103
//nolint:gocritic,revive // More cases will be added later.
91104
switch params.grantType {
92-
// TODO: Client creds, device code, refresh.
105+
// TODO: Client creds, device code.
106+
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
107+
token, err = refreshTokenGrant(ctx, db, app, defaultLifetime, params)
93108
default:
94109
token, err = authorizationCodeGrant(ctx, db, app, defaultLifetime, params)
95110
}
@@ -163,9 +178,6 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
163178
}
164179

165180
// Generate a refresh token.
166-
// The refresh token is not currently used or exposed though as API keys can
167-
// already be refreshed by just using them.
168-
// TODO: However, should we implement the refresh grant anyway?
169181
refreshToken, err := GenerateSecret()
170182
if err != nil {
171183
return oauth2.Token{}, err
@@ -244,10 +256,115 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
244256
}
245257

246258
return oauth2.Token{
247-
AccessToken: sessionToken,
248-
TokenType: "Bearer",
249-
// TODO: Exclude until refresh grant is implemented.
250-
// RefreshToken: refreshToken.formatted,
251-
// Expiry: key.ExpiresAt,
259+
AccessToken: sessionToken,
260+
TokenType: "Bearer",
261+
RefreshToken: refreshToken.Formatted,
262+
Expiry: key.ExpiresAt,
263+
}, nil
264+
}
265+
266+
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, defaultLifetime time.Duration, params tokenParams) (oauth2.Token, error) {
267+
// Validate the token.
268+
token, err := parseSecret(params.refreshToken)
269+
if err != nil {
270+
return oauth2.Token{}, errBadToken
271+
}
272+
//nolint:gocritic // There is no user yet so we must use the system.
273+
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.prefix))
274+
if errors.Is(err, sql.ErrNoRows) {
275+
return oauth2.Token{}, errBadToken
276+
}
277+
if err != nil {
278+
return oauth2.Token{}, err
279+
}
280+
equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.secret)
281+
if err != nil {
282+
return oauth2.Token{}, xerrors.Errorf("unable to compare token: %w", err)
283+
}
284+
if !equal {
285+
return oauth2.Token{}, errBadToken
286+
}
287+
288+
// Ensure the token has not expired.
289+
if dbToken.ExpiresAt.Before(dbtime.Now()) {
290+
return oauth2.Token{}, errBadToken
291+
}
292+
293+
// Grab the user roles so we can perform the refresh as the user.
294+
//nolint:gocritic // There is no user yet so we must use the system.
295+
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
296+
if err != nil {
297+
return oauth2.Token{}, err
298+
}
299+
//nolint:gocritic // There is no user yet so we must use the system.
300+
roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), prevKey.UserID)
301+
if err != nil {
302+
return oauth2.Token{}, err
303+
}
304+
userSubj := rbac.Subject{
305+
ID: prevKey.UserID.String(),
306+
Roles: rbac.RoleNames(roles.Roles),
307+
Groups: roles.Groups,
308+
Scope: rbac.ScopeAll,
309+
}
310+
311+
// Generate a new refresh token.
312+
refreshToken, err := GenerateSecret()
313+
if err != nil {
314+
return oauth2.Token{}, err
315+
}
316+
317+
// Generate the new API key.
318+
// TODO: We are ignoring scopes for now.
319+
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID)
320+
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
321+
UserID: prevKey.UserID,
322+
LoginType: database.LoginTypeOAuth2ProviderApp,
323+
// TODO: This is just the lifetime for api keys, maybe have its own config
324+
// settings. #11693
325+
DefaultLifetime: defaultLifetime,
326+
// For now, we allow only one token per app and user at a time.
327+
TokenName: tokenName,
328+
})
329+
if err != nil {
330+
return oauth2.Token{}, err
331+
}
332+
333+
// Replace the token.
334+
err = db.InTx(func(tx database.Store) error {
335+
ctx := dbauthz.As(ctx, userSubj)
336+
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
337+
if err != nil {
338+
return xerrors.Errorf("delete oauth2 app token: %w", err)
339+
}
340+
341+
newKey, err := tx.InsertAPIKey(ctx, key)
342+
if err != nil {
343+
return xerrors.Errorf("insert oauth2 access token: %w", err)
344+
}
345+
346+
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
347+
ID: uuid.New(),
348+
CreatedAt: dbtime.Now(),
349+
ExpiresAt: key.ExpiresAt,
350+
HashPrefix: []byte(refreshToken.Prefix),
351+
RefreshHash: []byte(refreshToken.Hashed),
352+
AppSecretID: dbToken.AppSecretID,
353+
APIKeyID: newKey.ID,
354+
})
355+
if err != nil {
356+
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
357+
}
358+
return nil
359+
}, nil)
360+
if err != nil {
361+
return oauth2.Token{}, err
362+
}
363+
364+
return oauth2.Token{
365+
AccessToken: sessionToken,
366+
TokenType: "Bearer",
367+
RefreshToken: refreshToken.Formatted,
368+
Expiry: key.ExpiresAt,
252369
}, nil
253370
}

enterprise/coderd/oauth2.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -300,9 +300,10 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
300300
// @ID oauth2-token-exchange
301301
// @Produce json
302302
// @Tags Enterprise
303-
// @Param client_id formData string true "Client ID"
304-
// @Param client_secret formData string true "Client secret"
305-
// @Param code formData string true "Authorization code"
303+
// @Param client_id formData string false "Client ID, required if grant_type=authorization_code"
304+
// @Param client_secret formData string false "Client secret, required if grant_type=authorization_code"
305+
// @Param code formData string false "Authorization code, required if grant_type=authorization_code"
306+
// @Param refresh_token formData string false "Refresh token, required if grant_type=refresh_token"
306307
// @Param grant_type formData codersdk.OAuth2ProviderGrantType true "Grant type"
307308
// @Success 200 {object} oauth2.Token
308309
// @Router /login/oauth2/tokens [post]

0 commit comments

Comments
 (0)