feat: oauth2 - add authorization server metadata endpoint and PKCE support (#18548)

## Summary

  This PR implements critical MCP OAuth2 compliance features for Coder's authorization server, adding PKCE support, resource parameter handling, and OAuth2 server metadata discovery. This brings Coder's OAuth2 implementation significantly closer to production readiness for MCP (Model Context Protocol)
  integrations.

  ## What's Added

  ### OAuth2 Authorization Server Metadata (RFC 8414)
  - Add `/.well-known/oauth-authorization-server` endpoint for automatic client discovery
  - Returns standardized metadata including supported grant types, response types, and PKCE methods
  - Essential for MCP client compatibility and OAuth2 standards compliance

  ### PKCE Support (RFC 7636)
  - Implement Proof Key for Code Exchange with S256 challenge method
  - Add `code_challenge` and `code_challenge_method` parameters to authorization flow
  - Add `code_verifier` validation in token exchange
  - Provides enhanced security for public clients (mobile apps, CLIs)

  ### Resource Parameter Support (RFC 8707)
  - Add `resource` parameter to authorization and token endpoints
  - Store resource URI and bind tokens to specific audiences
  - Critical for MCP's resource-bound token model

  ### Enhanced OAuth2 Error Handling
  - Add OAuth2-compliant error responses with proper error codes
  - Use standard error format: `{"error": "code", "error_description": "details"}`
  - Improve error consistency across OAuth2 endpoints

  ### Authorization UI Improvements
  - Fix authorization flow to use POST-based consent instead of GET redirects
  - Remove dependency on referer headers for security decisions
  - Improve CSRF protection with proper state parameter validation

  ## Why This Matters

  **For MCP Integration:** MCP requires OAuth2 authorization servers to support PKCE, resource parameters, and metadata discovery. Without these features, MCP clients cannot securely authenticate with Coder.

  **For Security:** PKCE prevents authorization code interception attacks, especially critical for public clients. Resource binding ensures tokens are only valid for intended services.

  **For Standards Compliance:** These are widely adopted OAuth2 extensions that improve interoperability with modern OAuth2 clients.

  ## Database Changes

  - **Migration 000343:** Adds `code_challenge`, `code_challenge_method`, `resource_uri` to `oauth2_provider_app_codes`
  - **Migration 000343:** Adds `audience` field to `oauth2_provider_app_tokens` for resource binding
  - **Audit Updates:** New OAuth2 fields properly tracked in audit system
  - **Backward Compatibility:** All changes maintain compatibility with existing OAuth2 flows

  ## Test Coverage

  - Comprehensive PKCE test suite in `coderd/identityprovider/pkce_test.go`
  - OAuth2 metadata endpoint tests in `coderd/oauth2_metadata_test.go`
  - Integration tests covering PKCE + resource parameter combinations
  - Negative tests for invalid PKCE verifiers and malformed requests

  ## Testing Instructions

  ```bash
  # Run the comprehensive OAuth2 test suite
  ./scripts/oauth2/test-mcp-oauth2.sh

  Manual Testing with Interactive Server

  # Start Coder in development mode
  ./scripts/develop.sh

  # In another terminal, set up test app and run interactive flow
  eval $(./scripts/oauth2/setup-test-app.sh)
  ./scripts/oauth2/test-manual-flow.sh
  # Opens browser with OAuth2 flow, handles callback automatically

  # Clean up when done
  ./scripts/oauth2/cleanup-test-app.sh

  Individual Component Testing

  # Test metadata endpoint
  curl -s http://localhost:3000/.well-known/oauth-authorization-server | jq .

  # Test PKCE generation
  ./scripts/oauth2/generate-pkce.sh

  # Run specific test suites
  go test -v ./coderd/identityprovider -run TestVerifyPKCE
  go test -v ./coderd -run TestOAuth2AuthorizationServerMetadata
```

  ### Breaking Changes

  None. All changes maintain backward compatibility with existing OAuth2 flows.

---

Change-Id: Ifbd0d9a543d545f9f56ecaa77ff2238542ff954a
Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
Thomas Kosiewski
2025-07-01 15:39:29 +02:00
committed by GitHub
parent dbfbef6ecb
commit 6f2834f62a
41 changed files with 2846 additions and 304 deletions
+165 -4
View File
@@ -2,6 +2,22 @@
Read [cursor rules](.cursorrules).
## Quick Start Checklist for New Features
### Before Starting
- [ ] Run `git pull` to ensure you're on latest code
- [ ] Check if feature touches database - you'll need migrations
- [ ] Check if feature touches audit logs - update `enterprise/audit/table.go`
## Development Server
### Starting Development Mode
- Use `./scripts/develop.sh` to start Coder in development mode
- This automatically builds and runs with `--dev` flag and proper access URL
- Do NOT manually run `make build && ./coder server --dev` - use the script instead
## Build/Test/Lint Commands
### Main Commands
@@ -34,6 +50,7 @@ Read [cursor rules](.cursorrules).
- Use `gofumpt` for formatting
- Create packages when used during implementation
- Validate abstractions against implementations
- **Test packages**: Use `package_test` naming (e.g., `identityprovider_test`) for black-box testing
### Error Handling
@@ -63,11 +80,50 @@ Read [cursor rules](.cursorrules).
- Keep message titles concise (~70 characters)
- Use imperative, present tense in commit titles
## Database queries
## Database Work
- MUST DO! Any changes to database - adding queries, modifying queries should be done in the `coderd\database\queries\*.sql` files. Use `make gen` to generate necessary changes after.
- MUST DO! Queries are grouped in files relating to context - e.g. `prebuilds.sql`, `users.sql`, `provisionerjobs.sql`.
- After making changes to any `coderd\database\queries\*.sql` files you must run `make gen` to generate respective ORM changes.
### Migration Guidelines
1. **Create migration files**:
- Location: `coderd/database/migrations/`
- Format: `{number}_{description}.{up|down}.sql`
- Number must be unique and sequential
- Always include both up and down migrations
2. **Update database queries**:
- MUST DO! Any changes to database - adding queries, modifying queries should be done in the `coderd/database/queries/*.sql` files
- MUST DO! Queries are grouped in files relating to context - e.g. `prebuilds.sql`, `users.sql`, `oauth2.sql`
- After making changes to any `coderd/database/queries/*.sql` files you must run `make gen` to generate respective ORM changes
3. **Handle nullable fields**:
- Use `sql.NullString`, `sql.NullBool`, etc. for optional database fields
- Set `.Valid = true` when providing values
- Example:
```go
CodeChallenge: sql.NullString{
String: params.codeChallenge,
Valid: params.codeChallenge != "",
}
```
4. **Audit table updates**:
- If adding fields to auditable types, update `enterprise/audit/table.go`
- Add each new field with appropriate action (ActionTrack, ActionIgnore, ActionSecret)
- Run `make gen` to verify no audit errors
5. **In-memory database (dbmem) updates**:
- When adding new fields to database structs, ensure `dbmem` implementation copies all fields
- Check `coderd/database/dbmem/dbmem.go` for Insert/Update methods
- Missing fields in dbmem can cause tests to fail even if main implementation is correct
### Database Generation Process
1. Modify SQL files in `coderd/database/queries/`
2. Run `make gen`
3. If errors about audit table, update `enterprise/audit/table.go`
4. Run `make gen` again
5. Run `make lint` to catch any remaining issues
## Architecture
@@ -78,6 +134,14 @@ Read [cursor rules](.cursorrules).
- **Agents**: Services in remote workspaces providing features like SSH and port forwarding
- **Workspaces**: Cloud resources defined by Terraform
### Adding New API Endpoints
1. **Define types** in `codersdk/` package
2. **Add handler** in appropriate `coderd/` file
3. **Register route** in `coderd/coderd.go`
4. **Add tests** in `coderd/*_test.go` files
5. **Update OpenAPI** by running `make gen`
## Sub-modules
### Template System
@@ -104,3 +168,100 @@ Read [cursor rules](.cursorrules).
The frontend is contained in the site folder.
For building Frontend refer to [this document](docs/about/contributing/frontend.md)
## Common Patterns
### OAuth2/Authentication Work
- Types go in `codersdk/oauth2.go` or similar
- Handlers go in `coderd/oauth2.go` or `coderd/identityprovider/`
- Database fields need migration + audit table updates
- Always support backward compatibility
## OAuth2 Development
### OAuth2 Provider Implementation
When working on OAuth2 provider features:
1. **OAuth2 Spec Compliance**:
- Follow RFC 6749 for token responses
- Use `expires_in` (seconds) not `expiry` (timestamp) in token responses
- Return proper OAuth2 error format: `{"error": "code", "error_description": "details"}`
2. **Error Response Format**:
- Create OAuth2-compliant error responses for token endpoint
- Use standard error codes: `invalid_client`, `invalid_grant`, `invalid_request`
- Avoid generic error responses for OAuth2 endpoints
3. **Testing OAuth2 Features**:
- Use scripts in `./scripts/oauth2/` for testing
- Run `./scripts/oauth2/test-mcp-oauth2.sh` for comprehensive tests
- Manual testing: use `./scripts/oauth2/test-manual-flow.sh`
4. **PKCE Implementation**:
- Support both with and without PKCE for backward compatibility
- Use S256 method for code challenge
- Properly validate code_verifier against stored code_challenge
5. **UI Authorization Flow**:
- Use POST requests for consent, not GET with links
- Avoid dependency on referer headers for security decisions
- Support proper state parameter validation
### OAuth2 Error Handling Pattern
```go
// Define specific OAuth2 errors
var (
errInvalidPKCE = xerrors.New("invalid code_verifier")
)
// Use OAuth2-compliant error responses
type OAuth2Error struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// Return proper OAuth2 errors
if errors.Is(err, errInvalidPKCE) {
writeOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The PKCE code verifier is invalid")
return
}
```
### Testing Patterns
- Use table-driven tests for comprehensive coverage
- Mock external dependencies
- Test both positive and negative cases
- Use `testutil.WaitLong` for timeouts in tests
## Testing Scripts
### OAuth2 Test Scripts
Located in `./scripts/oauth2/`:
- `test-mcp-oauth2.sh` - Full automated test suite
- `setup-test-app.sh` - Create test OAuth2 app
- `cleanup-test-app.sh` - Remove test app
- `generate-pkce.sh` - Generate PKCE parameters
- `test-manual-flow.sh` - Manual browser testing
Always run the full test suite after OAuth2 changes:
```bash
./scripts/oauth2/test-mcp-oauth2.sh
```
## Troubleshooting
### Common Issues
1. **"Audit table entry missing action"** - Update `enterprise/audit/table.go`
2. **"package should be X_test"** - Use `package_test` naming for test files
3. **SQL type errors** - Use `sql.Null*` types for nullable fields
4. **Missing newlines** - Ensure files end with newline character
5. **Tests passing locally but failing in CI** - Check if `dbmem` implementation needs updating
6. **OAuth2 endpoints returning wrong error format** - Ensure OAuth2 endpoints return RFC 6749 compliant errors
+125 -3
View File
@@ -45,6 +45,26 @@ const docTemplate = `{
}
}
},
"/.well-known/oauth-authorization-server": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "OAuth2 authorization server metadata.",
"operationId": "oauth2-authorization-server-metadata",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2AuthorizationServerMetadata"
}
}
}
}
},
"/appearance": {
"get": {
"security": [
@@ -2173,6 +2193,61 @@ const docTemplate = `{
}
},
"/oauth2/authorize": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Enterprise"
],
"summary": "OAuth2 authorization request (GET - show authorization page).",
"operationId": "oauth2-authorization-request-get",
"parameters": [
{
"type": "string",
"description": "Client ID",
"name": "client_id",
"in": "query",
"required": true
},
{
"type": "string",
"description": "A random unguessable string",
"name": "state",
"in": "query",
"required": true
},
{
"enum": [
"code"
],
"type": "string",
"description": "Response type",
"name": "response_type",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Redirect here after authorization",
"name": "redirect_uri",
"in": "query"
},
{
"type": "string",
"description": "Token scopes (currently ignored)",
"name": "scope",
"in": "query"
}
],
"responses": {
"200": {
"description": "Returns HTML authorization page"
}
}
},
"post": {
"security": [
{
@@ -2182,8 +2257,8 @@ const docTemplate = `{
"tags": [
"Enterprise"
],
"summary": "OAuth2 authorization request.",
"operationId": "oauth2-authorization-request",
"summary": "OAuth2 authorization request (POST - process authorization).",
"operationId": "oauth2-authorization-request-post",
"parameters": [
{
"type": "string",
@@ -2224,7 +2299,7 @@ const docTemplate = `{
],
"responses": {
"302": {
"description": "Found"
"description": "Returns redirect with authorization code"
}
}
}
@@ -13213,6 +13288,53 @@ const docTemplate = `{
}
}
},
"codersdk.OAuth2AuthorizationServerMetadata": {
"type": "object",
"properties": {
"authorization_endpoint": {
"type": "string"
},
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"issuer": {
"type": "string"
},
"registration_endpoint": {
"type": "string"
},
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"scopes_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"token_endpoint": {
"type": "string"
},
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"codersdk.OAuth2Config": {
"type": "object",
"properties": {
+117 -3
View File
@@ -33,6 +33,22 @@
}
}
},
"/.well-known/oauth-authorization-server": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "OAuth2 authorization server metadata.",
"operationId": "oauth2-authorization-server-metadata",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2AuthorizationServerMetadata"
}
}
}
}
},
"/appearance": {
"get": {
"security": [
@@ -1899,6 +1915,57 @@
}
},
"/oauth2/authorize": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Enterprise"],
"summary": "OAuth2 authorization request (GET - show authorization page).",
"operationId": "oauth2-authorization-request-get",
"parameters": [
{
"type": "string",
"description": "Client ID",
"name": "client_id",
"in": "query",
"required": true
},
{
"type": "string",
"description": "A random unguessable string",
"name": "state",
"in": "query",
"required": true
},
{
"enum": ["code"],
"type": "string",
"description": "Response type",
"name": "response_type",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Redirect here after authorization",
"name": "redirect_uri",
"in": "query"
},
{
"type": "string",
"description": "Token scopes (currently ignored)",
"name": "scope",
"in": "query"
}
],
"responses": {
"200": {
"description": "Returns HTML authorization page"
}
}
},
"post": {
"security": [
{
@@ -1906,8 +1973,8 @@
}
],
"tags": ["Enterprise"],
"summary": "OAuth2 authorization request.",
"operationId": "oauth2-authorization-request",
"summary": "OAuth2 authorization request (POST - process authorization).",
"operationId": "oauth2-authorization-request-post",
"parameters": [
{
"type": "string",
@@ -1946,7 +2013,7 @@
],
"responses": {
"302": {
"description": "Found"
"description": "Returns redirect with authorization code"
}
}
}
@@ -11897,6 +11964,53 @@
}
}
},
"codersdk.OAuth2AuthorizationServerMetadata": {
"type": "object",
"properties": {
"authorization_endpoint": {
"type": "string"
},
"code_challenge_methods_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"grant_types_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"issuer": {
"type": "string"
},
"registration_endpoint": {
"type": "string"
},
"response_types_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"scopes_supported": {
"type": "array",
"items": {
"type": "string"
}
},
"token_endpoint": {
"type": "string"
},
"token_endpoint_auth_methods_supported": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"codersdk.OAuth2Config": {
"type": "object",
"properties": {
+14 -4
View File
@@ -909,21 +909,31 @@ func New(options *Options) *API {
})
}
// OAuth2 metadata endpoint for RFC 8414 discovery
r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata)
// OAuth2 linking routes do not make sense under the /api/v2 path. These are
// for an external application to use Coder as an OAuth2 provider, not for
// logging into Coder with an external OAuth2 provider.
r.Route("/oauth2", func(r chi.Router) {
r.Use(
api.oAuth2ProviderMiddleware,
// Fetch the app as system because in the /tokens route there will be no
// authenticated user.
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
)
r.Route("/authorize", func(r chi.Router) {
r.Use(apiKeyMiddlewareRedirect)
r.Use(
// Fetch the app as system for the authorize endpoint
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
apiKeyMiddlewareRedirect,
)
// GET shows the consent page, POST processes the consent
r.Get("/", api.getOAuth2ProviderAppAuthorize())
r.Post("/", api.postOAuth2ProviderAppAuthorize())
})
r.Route("/tokens", func(r chi.Router) {
r.Use(
// Use OAuth2-compliant error responses for the tokens endpoint
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
)
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
// DELETE on /tokens is not part of the OAuth2 spec. It is our own
+13 -13
View File
@@ -5198,17 +5198,13 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() {
HashPrefix: []byte(fmt.Sprintf("%d", i)),
})
}
expectedApp := app
expectedApp.CreatedAt = createdAt
expectedApp.UpdatedAt = createdAt
check.Args(user.ID).Asserts(rbac.ResourceOauth2AppCodeToken.WithOwner(user.ID.String()), policy.ActionRead).Returns([]database.GetOAuth2ProviderAppsByUserIDRow{
{
OAuth2ProviderApp: database.OAuth2ProviderApp{
ID: app.ID,
CallbackURL: app.CallbackURL,
Icon: app.Icon,
Name: app.Name,
CreatedAt: createdAt,
UpdatedAt: createdAt,
},
TokenCount: 5,
OAuth2ProviderApp: expectedApp,
TokenCount: 5,
},
})
}))
@@ -5221,10 +5217,14 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() {
app.Name = "my-new-name"
app.UpdatedAt = dbtestutil.NowInDefaultTimezone()
check.Args(database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
Name: app.Name,
CallbackURL: app.CallbackURL,
UpdatedAt: app.UpdatedAt,
ID: app.ID,
Name: app.Name,
Icon: app.Icon,
CallbackURL: app.CallbackURL,
RedirectUris: app.RedirectUris,
ClientType: app.ClientType,
DynamicallyRegistered: app.DynamicallyRegistered,
UpdatedAt: app.UpdatedAt,
}).Asserts(rbac.ResourceOauth2App, policy.ActionUpdate).Returns(app)
}))
s.Run("DeleteOAuth2ProviderAppByID", s.Subtest(func(db database.Store, check *expects) {
+26 -13
View File
@@ -1131,12 +1131,21 @@ func WorkspaceAgentStat(t testing.TB, db database.Store, orig database.Workspace
func OAuth2ProviderApp(t testing.TB, db database.Store, seed database.OAuth2ProviderApp) database.OAuth2ProviderApp {
app, err := db.InsertOAuth2ProviderApp(genCtx, database.InsertOAuth2ProviderAppParams{
ID: takeFirst(seed.ID, uuid.New()),
Name: takeFirst(seed.Name, testutil.GetRandomName(t)),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
Icon: takeFirst(seed.Icon, ""),
CallbackURL: takeFirst(seed.CallbackURL, "http://localhost"),
ID: takeFirst(seed.ID, uuid.New()),
Name: takeFirst(seed.Name, testutil.GetRandomName(t)),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()),
Icon: takeFirst(seed.Icon, ""),
CallbackURL: takeFirst(seed.CallbackURL, "http://localhost"),
RedirectUris: takeFirstSlice(seed.RedirectUris, []string{}),
ClientType: takeFirst(seed.ClientType, sql.NullString{
String: "confidential",
Valid: true,
}),
DynamicallyRegistered: takeFirst(seed.DynamicallyRegistered, sql.NullBool{
Bool: false,
Valid: true,
}),
})
require.NoError(t, err, "insert oauth2 app")
return app
@@ -1157,13 +1166,16 @@ func OAuth2ProviderAppSecret(t testing.TB, db database.Store, seed database.OAut
func OAuth2ProviderAppCode(t testing.TB, db database.Store, seed database.OAuth2ProviderAppCode) database.OAuth2ProviderAppCode {
code, err := db.InsertOAuth2ProviderAppCode(genCtx, database.InsertOAuth2ProviderAppCodeParams{
ID: takeFirst(seed.ID, uuid.New()),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
ExpiresAt: takeFirst(seed.CreatedAt, dbtime.Now()),
SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")),
HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")),
AppID: takeFirst(seed.AppID, uuid.New()),
UserID: takeFirst(seed.UserID, uuid.New()),
ID: takeFirst(seed.ID, uuid.New()),
CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()),
ExpiresAt: takeFirst(seed.CreatedAt, dbtime.Now()),
SecretPrefix: takeFirstSlice(seed.SecretPrefix, []byte("prefix")),
HashedSecret: takeFirstSlice(seed.HashedSecret, []byte("hashed-secret")),
AppID: takeFirst(seed.AppID, uuid.New()),
UserID: takeFirst(seed.UserID, uuid.New()),
ResourceUri: seed.ResourceUri,
CodeChallenge: seed.CodeChallenge,
CodeChallengeMethod: seed.CodeChallengeMethod,
})
require.NoError(t, err, "insert oauth2 app code")
return code
@@ -1178,6 +1190,7 @@ func OAuth2ProviderAppToken(t testing.TB, db database.Store, seed database.OAuth
RefreshHash: takeFirstSlice(seed.RefreshHash, []byte("hashed-secret")),
AppSecretID: takeFirst(seed.AppSecretID, uuid.New()),
APIKeyID: takeFirst(seed.APIKeyID, uuid.New().String()),
Audience: seed.Audience,
})
require.NoError(t, err, "insert oauth2 app token")
return token
+10 -7
View File
@@ -8942,13 +8942,16 @@ func (q *FakeQuerier) InsertOAuth2ProviderAppCode(_ context.Context, arg databas
for _, app := range q.oauth2ProviderApps {
if app.ID == arg.AppID {
code := database.OAuth2ProviderAppCode{
ID: arg.ID,
CreatedAt: arg.CreatedAt,
ExpiresAt: arg.ExpiresAt,
SecretPrefix: arg.SecretPrefix,
HashedSecret: arg.HashedSecret,
UserID: arg.UserID,
AppID: arg.AppID,
ID: arg.ID,
CreatedAt: arg.CreatedAt,
ExpiresAt: arg.ExpiresAt,
SecretPrefix: arg.SecretPrefix,
HashedSecret: arg.HashedSecret,
UserID: arg.UserID,
AppID: arg.AppID,
ResourceUri: arg.ResourceUri,
CodeChallenge: arg.CodeChallenge,
CodeChallengeMethod: arg.CodeChallengeMethod,
}
q.oauth2ProviderAppCodes = append(q.oauth2ProviderAppCodes, code)
return code, nil
+24 -3
View File
@@ -1104,11 +1104,20 @@ CREATE TABLE oauth2_provider_app_codes (
secret_prefix bytea NOT NULL,
hashed_secret bytea NOT NULL,
user_id uuid NOT NULL,
app_id uuid NOT NULL
app_id uuid NOT NULL,
resource_uri text,
code_challenge text,
code_challenge_method text
);
COMMENT ON TABLE oauth2_provider_app_codes IS 'Codes are meant to be exchanged for access tokens.';
COMMENT ON COLUMN oauth2_provider_app_codes.resource_uri IS 'RFC 8707 resource parameter for audience restriction';
COMMENT ON COLUMN oauth2_provider_app_codes.code_challenge IS 'PKCE code challenge for public clients';
COMMENT ON COLUMN oauth2_provider_app_codes.code_challenge_method IS 'PKCE challenge method (S256)';
CREATE TABLE oauth2_provider_app_secrets (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -1128,22 +1137,34 @@ CREATE TABLE oauth2_provider_app_tokens (
hash_prefix bytea NOT NULL,
refresh_hash bytea NOT NULL,
app_secret_id uuid NOT NULL,
api_key_id text NOT NULL
api_key_id text NOT NULL,
audience text
);
COMMENT ON COLUMN oauth2_provider_app_tokens.refresh_hash IS 'Refresh tokens provide a way to refresh an access token (API key). An expired API key can be refreshed if this token is not yet expired, meaning this expiry can outlive an API key.';
COMMENT ON COLUMN oauth2_provider_app_tokens.audience IS 'Token audience binding from resource parameter';
CREATE TABLE oauth2_provider_apps (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
name character varying(64) NOT NULL,
icon character varying(256) NOT NULL,
callback_url text NOT NULL
callback_url text NOT NULL,
redirect_uris text[],
client_type text DEFAULT 'confidential'::text,
dynamically_registered boolean DEFAULT false
);
COMMENT ON TABLE oauth2_provider_apps IS 'A table used to configure apps that can use Coder as an OAuth2 provider, the reverse of what we are calling external authentication.';
COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'List of valid redirect URIs for the application';
COMMENT ON COLUMN oauth2_provider_apps.client_type IS 'OAuth2 client type: confidential or public';
COMMENT ON COLUMN oauth2_provider_apps.dynamically_registered IS 'Whether this app was created via dynamic client registration';
CREATE TABLE organizations (
id uuid NOT NULL,
name text NOT NULL,
@@ -0,0 +1,17 @@
-- Remove OAuth2 extension fields
-- Remove fields from oauth2_provider_apps
ALTER TABLE oauth2_provider_apps
DROP COLUMN IF EXISTS redirect_uris,
DROP COLUMN IF EXISTS client_type,
DROP COLUMN IF EXISTS dynamically_registered;
-- Remove audience field from oauth2_provider_app_tokens
ALTER TABLE oauth2_provider_app_tokens
DROP COLUMN IF EXISTS audience;
-- Remove PKCE and resource fields from oauth2_provider_app_codes
ALTER TABLE oauth2_provider_app_codes
DROP COLUMN IF EXISTS code_challenge_method,
DROP COLUMN IF EXISTS code_challenge,
DROP COLUMN IF EXISTS resource_uri;
@@ -0,0 +1,38 @@
-- Add OAuth2 extension fields for RFC 8707 resource indicators, PKCE, and dynamic client registration
-- Add resource_uri field to oauth2_provider_app_codes for RFC 8707 resource parameter
ALTER TABLE oauth2_provider_app_codes
ADD COLUMN resource_uri text;
COMMENT ON COLUMN oauth2_provider_app_codes.resource_uri IS 'RFC 8707 resource parameter for audience restriction';
-- Add PKCE fields to oauth2_provider_app_codes
ALTER TABLE oauth2_provider_app_codes
ADD COLUMN code_challenge text,
ADD COLUMN code_challenge_method text;
COMMENT ON COLUMN oauth2_provider_app_codes.code_challenge IS 'PKCE code challenge for public clients';
COMMENT ON COLUMN oauth2_provider_app_codes.code_challenge_method IS 'PKCE challenge method (S256)';
-- Add audience field to oauth2_provider_app_tokens for token binding
ALTER TABLE oauth2_provider_app_tokens
ADD COLUMN audience text;
COMMENT ON COLUMN oauth2_provider_app_tokens.audience IS 'Token audience binding from resource parameter';
-- Add fields to oauth2_provider_apps for future dynamic registration and redirect URI management
ALTER TABLE oauth2_provider_apps
ADD COLUMN redirect_uris text[], -- Store multiple URIs for future use
ADD COLUMN client_type text DEFAULT 'confidential', -- 'confidential' or 'public'
ADD COLUMN dynamically_registered boolean DEFAULT false;
-- Backfill existing records with default values
UPDATE oauth2_provider_apps SET
redirect_uris = COALESCE(redirect_uris, '{}'),
client_type = COALESCE(client_type, 'confidential'),
dynamically_registered = COALESCE(dynamically_registered, false)
WHERE redirect_uris IS NULL OR client_type IS NULL OR dynamically_registered IS NULL;
COMMENT ON COLUMN oauth2_provider_apps.redirect_uris IS 'List of valid redirect URIs for the application';
COMMENT ON COLUMN oauth2_provider_apps.client_type IS 'OAuth2 client type: confidential or public';
COMMENT ON COLUMN oauth2_provider_apps.dynamically_registered IS 'Whether this app was created via dynamic client registration';
+14
View File
@@ -2980,6 +2980,12 @@ type OAuth2ProviderApp struct {
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
// List of valid redirect URIs for the application
RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
// OAuth2 client type: confidential or public
ClientType sql.NullString `db:"client_type" json:"client_type"`
// Whether this app was created via dynamic client registration
DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
}
// Codes are meant to be exchanged for access tokens.
@@ -2991,6 +2997,12 @@ type OAuth2ProviderAppCode struct {
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
// RFC 8707 resource parameter for audience restriction
ResourceUri sql.NullString `db:"resource_uri" json:"resource_uri"`
// PKCE code challenge for public clients
CodeChallenge sql.NullString `db:"code_challenge" json:"code_challenge"`
// PKCE challenge method (S256)
CodeChallengeMethod sql.NullString `db:"code_challenge_method" json:"code_challenge_method"`
}
type OAuth2ProviderAppSecret struct {
@@ -3013,6 +3025,8 @@ type OAuth2ProviderAppToken struct {
RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"`
AppSecretID uuid.UUID `db:"app_secret_id" json:"app_secret_id"`
APIKeyID string `db:"api_key_id" json:"api_key_id"`
// Token audience binding from resource parameter
Audience sql.NullString `db:"audience" json:"audience"`
}
type Organization struct {
+105 -42
View File
@@ -4823,7 +4823,7 @@ func (q *sqlQuerier) DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx context.Con
}
const getOAuth2ProviderAppByID = `-- name: GetOAuth2ProviderAppByID :one
SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps WHERE id = $1
SELECT id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered FROM oauth2_provider_apps WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
@@ -4836,12 +4836,15 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID)
&i.Name,
&i.Icon,
&i.CallbackURL,
pq.Array(&i.RedirectUris),
&i.ClientType,
&i.DynamicallyRegistered,
)
return i, err
}
const getOAuth2ProviderAppCodeByID = `-- name: GetOAuth2ProviderAppCodeByID :one
SELECT id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id FROM oauth2_provider_app_codes WHERE id = $1
SELECT id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id, resource_uri, code_challenge, code_challenge_method FROM oauth2_provider_app_codes WHERE id = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) {
@@ -4855,12 +4858,15 @@ func (q *sqlQuerier) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.U
&i.HashedSecret,
&i.UserID,
&i.AppID,
&i.ResourceUri,
&i.CodeChallenge,
&i.CodeChallengeMethod,
)
return i, err
}
const getOAuth2ProviderAppCodeByPrefix = `-- name: GetOAuth2ProviderAppCodeByPrefix :one
SELECT id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id FROM oauth2_provider_app_codes WHERE secret_prefix = $1
SELECT id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id, resource_uri, code_challenge, code_challenge_method FROM oauth2_provider_app_codes WHERE secret_prefix = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) {
@@ -4874,6 +4880,9 @@ func (q *sqlQuerier) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secre
&i.HashedSecret,
&i.UserID,
&i.AppID,
&i.ResourceUri,
&i.CodeChallenge,
&i.CodeChallengeMethod,
)
return i, err
}
@@ -4952,7 +4961,7 @@ func (q *sqlQuerier) GetOAuth2ProviderAppSecretsByAppID(ctx context.Context, app
}
const getOAuth2ProviderAppTokenByPrefix = `-- name: GetOAuth2ProviderAppTokenByPrefix :one
SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id FROM oauth2_provider_app_tokens WHERE hash_prefix = $1
SELECT id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience FROM oauth2_provider_app_tokens WHERE hash_prefix = $1
`
func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hashPrefix []byte) (OAuth2ProviderAppToken, error) {
@@ -4966,12 +4975,13 @@ func (q *sqlQuerier) GetOAuth2ProviderAppTokenByPrefix(ctx context.Context, hash
&i.RefreshHash,
&i.AppSecretID,
&i.APIKeyID,
&i.Audience,
)
return i, err
}
const getOAuth2ProviderApps = `-- name: GetOAuth2ProviderApps :many
SELECT id, created_at, updated_at, name, icon, callback_url FROM oauth2_provider_apps ORDER BY (name, id) ASC
SELECT id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered FROM oauth2_provider_apps ORDER BY (name, id) ASC
`
func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) {
@@ -4990,6 +5000,9 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide
&i.Name,
&i.Icon,
&i.CallbackURL,
pq.Array(&i.RedirectUris),
&i.ClientType,
&i.DynamicallyRegistered,
); err != nil {
return nil, err
}
@@ -5007,7 +5020,7 @@ func (q *sqlQuerier) GetOAuth2ProviderApps(ctx context.Context) ([]OAuth2Provide
const getOAuth2ProviderAppsByUserID = `-- name: GetOAuth2ProviderAppsByUserID :many
SELECT
COUNT(DISTINCT oauth2_provider_app_tokens.id) as token_count,
oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.callback_url
oauth2_provider_apps.id, oauth2_provider_apps.created_at, oauth2_provider_apps.updated_at, oauth2_provider_apps.name, oauth2_provider_apps.icon, oauth2_provider_apps.callback_url, oauth2_provider_apps.redirect_uris, oauth2_provider_apps.client_type, oauth2_provider_apps.dynamically_registered
FROM oauth2_provider_app_tokens
INNER JOIN oauth2_provider_app_secrets
ON oauth2_provider_app_secrets.id = oauth2_provider_app_tokens.app_secret_id
@@ -5043,6 +5056,9 @@ func (q *sqlQuerier) GetOAuth2ProviderAppsByUserID(ctx context.Context, userID u
&i.OAuth2ProviderApp.Name,
&i.OAuth2ProviderApp.Icon,
&i.OAuth2ProviderApp.CallbackURL,
pq.Array(&i.OAuth2ProviderApp.RedirectUris),
&i.OAuth2ProviderApp.ClientType,
&i.OAuth2ProviderApp.DynamicallyRegistered,
); err != nil {
return nil, err
}
@@ -5064,24 +5080,33 @@ INSERT INTO oauth2_provider_apps (
updated_at,
name,
icon,
callback_url
callback_url,
redirect_uris,
client_type,
dynamically_registered
) VALUES(
$1,
$2,
$3,
$4,
$5,
$6
) RETURNING id, created_at, updated_at, name, icon, callback_url
$6,
$7,
$8,
$9
) RETURNING id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered
`
type InsertOAuth2ProviderAppParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
ClientType sql.NullString `db:"client_type" json:"client_type"`
DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
}
func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAuth2ProviderAppParams) (OAuth2ProviderApp, error) {
@@ -5092,6 +5117,9 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut
arg.Name,
arg.Icon,
arg.CallbackURL,
pq.Array(arg.RedirectUris),
arg.ClientType,
arg.DynamicallyRegistered,
)
var i OAuth2ProviderApp
err := row.Scan(
@@ -5101,6 +5129,9 @@ func (q *sqlQuerier) InsertOAuth2ProviderApp(ctx context.Context, arg InsertOAut
&i.Name,
&i.Icon,
&i.CallbackURL,
pq.Array(&i.RedirectUris),
&i.ClientType,
&i.DynamicallyRegistered,
)
return i, err
}
@@ -5113,7 +5144,10 @@ INSERT INTO oauth2_provider_app_codes (
secret_prefix,
hashed_secret,
app_id,
user_id
user_id,
resource_uri,
code_challenge,
code_challenge_method
) VALUES(
$1,
$2,
@@ -5121,18 +5155,24 @@ INSERT INTO oauth2_provider_app_codes (
$4,
$5,
$6,
$7
) RETURNING id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id
$7,
$8,
$9,
$10
) RETURNING id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id, resource_uri, code_challenge, code_challenge_method
`
type InsertOAuth2ProviderAppCodeParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
SecretPrefix []byte `db:"secret_prefix" json:"secret_prefix"`
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
AppID uuid.UUID `db:"app_id" json:"app_id"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
ResourceUri sql.NullString `db:"resource_uri" json:"resource_uri"`
CodeChallenge sql.NullString `db:"code_challenge" json:"code_challenge"`
CodeChallengeMethod sql.NullString `db:"code_challenge_method" json:"code_challenge_method"`
}
func (q *sqlQuerier) InsertOAuth2ProviderAppCode(ctx context.Context, arg InsertOAuth2ProviderAppCodeParams) (OAuth2ProviderAppCode, error) {
@@ -5144,6 +5184,9 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppCode(ctx context.Context, arg Insert
arg.HashedSecret,
arg.AppID,
arg.UserID,
arg.ResourceUri,
arg.CodeChallenge,
arg.CodeChallengeMethod,
)
var i OAuth2ProviderAppCode
err := row.Scan(
@@ -5154,6 +5197,9 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppCode(ctx context.Context, arg Insert
&i.HashedSecret,
&i.UserID,
&i.AppID,
&i.ResourceUri,
&i.CodeChallenge,
&i.CodeChallengeMethod,
)
return i, err
}
@@ -5215,7 +5261,8 @@ INSERT INTO oauth2_provider_app_tokens (
hash_prefix,
refresh_hash,
app_secret_id,
api_key_id
api_key_id,
audience
) VALUES(
$1,
$2,
@@ -5223,18 +5270,20 @@ INSERT INTO oauth2_provider_app_tokens (
$4,
$5,
$6,
$7
) RETURNING id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id
$7,
$8
) RETURNING id, created_at, expires_at, hash_prefix, refresh_hash, app_secret_id, api_key_id, audience
`
type InsertOAuth2ProviderAppTokenParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
HashPrefix []byte `db:"hash_prefix" json:"hash_prefix"`
RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"`
AppSecretID uuid.UUID `db:"app_secret_id" json:"app_secret_id"`
APIKeyID string `db:"api_key_id" json:"api_key_id"`
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
HashPrefix []byte `db:"hash_prefix" json:"hash_prefix"`
RefreshHash []byte `db:"refresh_hash" json:"refresh_hash"`
AppSecretID uuid.UUID `db:"app_secret_id" json:"app_secret_id"`
APIKeyID string `db:"api_key_id" json:"api_key_id"`
Audience sql.NullString `db:"audience" json:"audience"`
}
func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg InsertOAuth2ProviderAppTokenParams) (OAuth2ProviderAppToken, error) {
@@ -5246,6 +5295,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser
arg.RefreshHash,
arg.AppSecretID,
arg.APIKeyID,
arg.Audience,
)
var i OAuth2ProviderAppToken
err := row.Scan(
@@ -5256,6 +5306,7 @@ func (q *sqlQuerier) InsertOAuth2ProviderAppToken(ctx context.Context, arg Inser
&i.RefreshHash,
&i.AppSecretID,
&i.APIKeyID,
&i.Audience,
)
return i, err
}
@@ -5265,16 +5316,22 @@ UPDATE oauth2_provider_apps SET
updated_at = $2,
name = $3,
icon = $4,
callback_url = $5
WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, callback_url
callback_url = $5,
redirect_uris = $6,
client_type = $7,
dynamically_registered = $8
WHERE id = $1 RETURNING id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered
`
type UpdateOAuth2ProviderAppByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
ID uuid.UUID `db:"id" json:"id"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
CallbackURL string `db:"callback_url" json:"callback_url"`
RedirectUris []string `db:"redirect_uris" json:"redirect_uris"`
ClientType sql.NullString `db:"client_type" json:"client_type"`
DynamicallyRegistered sql.NullBool `db:"dynamically_registered" json:"dynamically_registered"`
}
func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg UpdateOAuth2ProviderAppByIDParams) (OAuth2ProviderApp, error) {
@@ -5284,6 +5341,9 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update
arg.Name,
arg.Icon,
arg.CallbackURL,
pq.Array(arg.RedirectUris),
arg.ClientType,
arg.DynamicallyRegistered,
)
var i OAuth2ProviderApp
err := row.Scan(
@@ -5293,6 +5353,9 @@ func (q *sqlQuerier) UpdateOAuth2ProviderAppByID(ctx context.Context, arg Update
&i.Name,
&i.Icon,
&i.CallbackURL,
pq.Array(&i.RedirectUris),
&i.ClientType,
&i.DynamicallyRegistered,
)
return i, err
}
+24 -7
View File
@@ -11,14 +11,20 @@ INSERT INTO oauth2_provider_apps (
updated_at,
name,
icon,
callback_url
callback_url,
redirect_uris,
client_type,
dynamically_registered
) VALUES(
$1,
$2,
$3,
$4,
$5,
$6
$6,
$7,
$8,
$9
) RETURNING *;
-- name: UpdateOAuth2ProviderAppByID :one
@@ -26,7 +32,10 @@ UPDATE oauth2_provider_apps SET
updated_at = $2,
name = $3,
icon = $4,
callback_url = $5
callback_url = $5,
redirect_uris = $6,
client_type = $7,
dynamically_registered = $8
WHERE id = $1 RETURNING *;
-- name: DeleteOAuth2ProviderAppByID :exec
@@ -80,7 +89,10 @@ INSERT INTO oauth2_provider_app_codes (
secret_prefix,
hashed_secret,
app_id,
user_id
user_id,
resource_uri,
code_challenge,
code_challenge_method
) VALUES(
$1,
$2,
@@ -88,7 +100,10 @@ INSERT INTO oauth2_provider_app_codes (
$4,
$5,
$6,
$7
$7,
$8,
$9,
$10
) RETURNING *;
-- name: DeleteOAuth2ProviderAppCodeByID :exec
@@ -105,7 +120,8 @@ INSERT INTO oauth2_provider_app_tokens (
hash_prefix,
refresh_hash,
app_secret_id,
api_key_id
api_key_id,
audience
) VALUES(
$1,
$2,
@@ -113,7 +129,8 @@ INSERT INTO oauth2_provider_app_tokens (
$4,
$5,
$6,
$7
$7,
$8
) RETURNING *;
-- name: GetOAuth2ProviderAppTokenByPrefix :one
+15
View File
@@ -493,3 +493,18 @@ func OneWayWebSocketEventSender(rw http.ResponseWriter, r *http.Request) (
return sendEvent, closed, nil
}
// OAuth2Error represents an OAuth2-compliant error response per RFC 6749.
type OAuth2Error struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// WriteOAuth2Error writes an OAuth2-compliant error response per RFC 6749.
// This should be used for all OAuth2 endpoints (/oauth2/*) to ensure compliance.
func WriteOAuth2Error(ctx context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
Write(ctx, rw, status, OAuth2Error{
Error: errorCode,
ErrorDescription: description,
})
}
+64 -8
View File
@@ -207,6 +207,67 @@ func OAuth2ProviderApp(r *http.Request) database.OAuth2ProviderApp {
// middleware requires the API key middleware higher in the call stack for
// authentication.
func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler {
return extractOAuth2ProviderAppBase(db, &codersdkErrorWriter{})
}
// ExtractOAuth2ProviderAppWithOAuth2Errors is the same as ExtractOAuth2ProviderApp but
// returns OAuth2-compliant errors instead of generic API errors. This should be used
// for OAuth2 endpoints like /oauth2/tokens.
func ExtractOAuth2ProviderAppWithOAuth2Errors(db database.Store) func(http.Handler) http.Handler {
return extractOAuth2ProviderAppBase(db, &oauth2ErrorWriter{})
}
// errorWriter interface abstracts different error response formats.
// This uses the Strategy pattern to avoid a control flag (useOAuth2Errors bool)
// which was flagged by the linter as an anti-pattern. Instead of duplicating
// the entire function logic or using a boolean parameter, we inject the error
// handling behavior through this interface.
type errorWriter interface {
writeMissingClientID(ctx context.Context, rw http.ResponseWriter)
writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, err error)
writeInvalidClient(ctx context.Context, rw http.ResponseWriter)
}
// codersdkErrorWriter writes standard codersdk errors for general API endpoints
type codersdkErrorWriter struct{}
func (*codersdkErrorWriter) writeMissingClientID(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing OAuth2 client ID.",
})
}
func (*codersdkErrorWriter) writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, err error) {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid OAuth2 client ID.",
Detail: err.Error(),
})
}
func (*codersdkErrorWriter) writeInvalidClient(ctx context.Context, rw http.ResponseWriter) {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid OAuth2 client.",
})
}
// oauth2ErrorWriter writes OAuth2-compliant errors for OAuth2 endpoints
type oauth2ErrorWriter struct{}
func (*oauth2ErrorWriter) writeMissingClientID(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Missing client_id parameter")
}
func (*oauth2ErrorWriter) writeInvalidClientID(ctx context.Context, rw http.ResponseWriter, _ error) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
}
func (*oauth2ErrorWriter) writeInvalidClient(ctx context.Context, rw http.ResponseWriter) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
}
// extractOAuth2ProviderAppBase is the internal implementation that uses the strategy pattern
// instead of a control flag to handle different error formats.
func extractOAuth2ProviderAppBase(db database.Store, errWriter errorWriter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -233,26 +294,21 @@ func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler
}
}
if paramAppID == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing OAuth2 client ID.",
})
errWriter.writeMissingClientID(ctx, rw)
return
}
var err error
appID, err = uuid.Parse(paramAppID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid OAuth2 client ID.",
Detail: err.Error(),
})
errWriter.writeInvalidClientID(ctx, rw, err)
return
}
}
app, err := db.GetOAuth2ProviderAppByID(ctx, appID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
errWriter.writeInvalidClient(ctx, rw)
return
}
if err != nil {
+59 -41
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
@@ -18,11 +19,14 @@ import (
)
type authorizeParams struct {
clientID string
redirectURL *url.URL
responseType codersdk.OAuth2ProviderResponseType
scope []string
state string
clientID string
redirectURL *url.URL
responseType codersdk.OAuth2ProviderResponseType
scope []string
state string
resource string // RFC 8707 resource indicator
codeChallenge string // PKCE code challenge
codeChallengeMethod string // PKCE challenge method
}
func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizeParams, []codersdk.ValidationError, error) {
@@ -32,28 +36,39 @@ func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizePar
p.RequiredNotEmpty("state", "response_type", "client_id")
params := authorizeParams{
clientID: p.String(vals, "", "client_id"),
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
scope: p.Strings(vals, []string{}, "scope"),
state: p.String(vals, "", "state"),
clientID: p.String(vals, "", "client_id"),
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
scope: p.Strings(vals, []string{}, "scope"),
state: p.String(vals, "", "state"),
resource: p.String(vals, "", "resource"),
codeChallenge: p.String(vals, "", "code_challenge"),
codeChallengeMethod: p.String(vals, "", "code_challenge_method"),
}
// We add "redirected" when coming from the authorize page.
_ = p.String(vals, "", "redirected")
p.ErrorExcessParams(vals)
if len(p.Errors) > 0 {
return authorizeParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
// Create a readable error message with validation details
var errorDetails []string
for _, err := range p.Errors {
errorDetails = append(errorDetails, err.Error())
}
errorMsg := "Invalid query params: " + strings.Join(errorDetails, ", ")
return authorizeParams{}, p.Errors, xerrors.Errorf(errorMsg)
}
return params, nil, nil
}
// Authorize displays an HTML page for authorizing an application when the user
// has first been redirected to this path and generates a code and redirects to
// the app's callback URL after the user clicks "allow" on that page, which is
// detected via the origin and referer headers.
func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
// ShowAuthorizePage handles GET /oauth2/authorize requests to display the HTML authorization page.
// It uses authorizeMW which intercepts GET requests to show the authorization form.
func ShowAuthorizePage(db database.Store, accessURL *url.URL) http.HandlerFunc {
handler := authorizeMW(accessURL)(ProcessAuthorize(db, accessURL))
return handler.ServeHTTP
}
// ProcessAuthorize handles POST /oauth2/authorize requests to process the user's authorization decision
// and generate an authorization code. GET requests are handled by authorizeMW.
func ProcessAuthorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
handler := func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
@@ -61,29 +76,32 @@ func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
callbackURL, err := url.Parse(app.CallbackURL)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to validate query parameters.",
Detail: err.Error(),
})
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to validate query parameters")
return
}
params, validationErrs, err := extractAuthorizeParams(r, callbackURL)
params, _, err := extractAuthorizeParams(r, callbackURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query params.",
Detail: err.Error(),
Validations: validationErrs,
})
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", err.Error())
return
}
// Validate PKCE for public clients (MCP requirement)
if params.codeChallenge != "" {
// If code_challenge is provided but method is not, default to S256
if params.codeChallengeMethod == "" {
params.codeChallengeMethod = "S256"
}
if params.codeChallengeMethod != "S256" {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid code_challenge_method: only S256 is supported")
return
}
}
// TODO: Ignoring scope for now, but should look into implementing.
code, err := GenerateSecret()
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate OAuth2 app authorization code.",
})
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 app authorization code")
return
}
err = db.InTx(func(tx database.Store) error {
@@ -107,11 +125,14 @@ func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
// is received. If the application does wait before exchanging the
// token (for example suppose they ask the user to confirm and the user
// has left) then they can just retry immediately and get a new code.
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
SecretPrefix: []byte(code.Prefix),
HashedSecret: []byte(code.Hashed),
AppID: app.ID,
UserID: apiKey.UserID,
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
SecretPrefix: []byte(code.Prefix),
HashedSecret: []byte(code.Hashed),
AppID: app.ID,
UserID: apiKey.UserID,
ResourceUri: sql.NullString{String: params.resource, Valid: params.resource != ""},
CodeChallenge: sql.NullString{String: params.codeChallenge, Valid: params.codeChallenge != ""},
CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""},
})
if err != nil {
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
@@ -120,10 +141,7 @@ func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to generate OAuth2 authorization code.",
Detail: err.Error(),
})
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 authorization code")
return
}
@@ -0,0 +1,41 @@
package identityprovidertest
import (
"crypto/sha256"
"encoding/base64"
)
// Test constants for OAuth2 testing
const (
// TestRedirectURI is the standard test redirect URI
TestRedirectURI = "http://localhost:9876/callback"
// TestResourceURI is used for testing resource parameter
TestResourceURI = "https://api.example.com"
// Invalid PKCE verifier for negative testing
InvalidCodeVerifier = "wrong-verifier"
)
// OAuth2ErrorTypes contains standard OAuth2 error codes
var OAuth2ErrorTypes = struct {
InvalidRequest string
InvalidClient string
InvalidGrant string
UnauthorizedClient string
UnsupportedGrantType string
InvalidScope string
}{
InvalidRequest: "invalid_request",
InvalidClient: "invalid_client",
InvalidGrant: "invalid_grant",
UnauthorizedClient: "unauthorized_client",
UnsupportedGrantType: "unsupported_grant_type",
InvalidScope: "invalid_scope",
}
// GenerateCodeChallenge creates an S256 code challenge from a verifier
func GenerateCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
@@ -0,0 +1,328 @@
// Package identityprovidertest provides comprehensive testing utilities for OAuth2 identity provider functionality.
// It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
// PKCE challenge generation and verification, and testing error scenarios.
package identityprovidertest
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// AuthorizeParams contains parameters for OAuth2 authorization
type AuthorizeParams struct {
ClientID string
ResponseType string
RedirectURI string
State string
CodeChallenge string
CodeChallengeMethod string
Resource string
Scope string
}
// TokenExchangeParams contains parameters for token exchange
type TokenExchangeParams struct {
GrantType string
Code string
ClientID string
ClientSecret string
CodeVerifier string
RedirectURI string
RefreshToken string
Resource string
}
// OAuth2Error represents an OAuth2 error response
type OAuth2Error struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// CreateTestOAuth2App creates an OAuth2 app for testing and returns the app and client secret
func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth2ProviderApp, string) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
// Create unique app name with random suffix
appName := fmt.Sprintf("test-oauth2-app-%s", testutil.MustRandString(t, 10))
req := codersdk.PostOAuth2ProviderAppRequest{
Name: appName,
CallbackURL: TestRedirectURI,
}
app, err := client.PostOAuth2ProviderApp(ctx, req)
require.NoError(t, err, "failed to create OAuth2 app")
// Create client secret
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
require.NoError(t, err, "failed to create OAuth2 app secret")
return &app, secret.ClientSecretFull
}
// GeneratePKCE generates a random PKCE code verifier and challenge
func GeneratePKCE(t *testing.T) (verifier, challenge string) {
t.Helper()
// Generate 32 random bytes for verifier
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
require.NoError(t, err, "failed to generate random bytes")
// Create code verifier (base64url encoding without padding)
verifier = base64.RawURLEncoding.EncodeToString(bytes)
// Create code challenge using S256 method
challenge = GenerateCodeChallenge(verifier)
return verifier, challenge
}
// GenerateState generates a random state parameter
func GenerateState(t *testing.T) string {
t.Helper()
bytes := make([]byte, 16)
_, err := rand.Read(bytes)
require.NoError(t, err, "failed to generate random bytes")
return base64.RawURLEncoding.EncodeToString(bytes)
}
// AuthorizeOAuth2App performs the OAuth2 authorization flow and returns the authorization code
func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) string {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
// Build authorization URL
authURL, err := url.Parse(baseURL + "/oauth2/authorize")
require.NoError(t, err, "failed to parse authorization URL")
query := url.Values{}
query.Set("client_id", params.ClientID)
query.Set("response_type", params.ResponseType)
query.Set("redirect_uri", params.RedirectURI)
query.Set("state", params.State)
if params.CodeChallenge != "" {
query.Set("code_challenge", params.CodeChallenge)
query.Set("code_challenge_method", params.CodeChallengeMethod)
}
if params.Resource != "" {
query.Set("resource", params.Resource)
}
if params.Scope != "" {
query.Set("scope", params.Scope)
}
authURL.RawQuery = query.Encode()
// Create POST request to authorize endpoint (simulating user clicking "Allow")
req, err := http.NewRequestWithContext(ctx, "POST", authURL.String(), nil)
require.NoError(t, err, "failed to create authorization request")
// Add session token
req.Header.Set("Coder-Session-Token", client.SessionToken())
// Perform request
httpClient := &http.Client{
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
// Don't follow redirects, we want to capture the redirect URL
return http.ErrUseLastResponse
},
}
resp, err := httpClient.Do(req)
require.NoError(t, err, "failed to perform authorization request")
defer resp.Body.Close()
// Should get a redirect response (either 302 Found or 307 Temporary Redirect)
require.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusTemporaryRedirect,
"expected redirect response, got %d", resp.StatusCode)
// Extract redirect URL
location := resp.Header.Get("Location")
require.NotEmpty(t, location, "missing Location header in redirect response")
// Parse redirect URL to extract authorization code
redirectURL, err := url.Parse(location)
require.NoError(t, err, "failed to parse redirect URL")
code := redirectURL.Query().Get("code")
require.NotEmpty(t, code, "missing authorization code in redirect URL")
// Verify state parameter
returnedState := redirectURL.Query().Get("state")
require.Equal(t, params.State, returnedState, "state parameter mismatch")
return code
}
// ExchangeCodeForToken exchanges an authorization code for tokens
func ExchangeCodeForToken(t *testing.T, baseURL string, params TokenExchangeParams) *oauth2.Token {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
// Prepare form data
data := url.Values{}
data.Set("grant_type", params.GrantType)
if params.Code != "" {
data.Set("code", params.Code)
}
if params.ClientID != "" {
data.Set("client_id", params.ClientID)
}
if params.ClientSecret != "" {
data.Set("client_secret", params.ClientSecret)
}
if params.CodeVerifier != "" {
data.Set("code_verifier", params.CodeVerifier)
}
if params.RedirectURI != "" {
data.Set("redirect_uri", params.RedirectURI)
}
if params.RefreshToken != "" {
data.Set("refresh_token", params.RefreshToken)
}
if params.Resource != "" {
data.Set("resource", params.Resource)
}
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
require.NoError(t, err, "failed to create token request")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Perform request
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err, "failed to perform token request")
defer resp.Body.Close()
// Parse response
var tokenResp oauth2.Token
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
require.NoError(t, err, "failed to decode token response")
require.NotEmpty(t, tokenResp.AccessToken, "missing access token")
require.Equal(t, "Bearer", tokenResp.TokenType, "unexpected token type")
return &tokenResp
}
// RequireOAuth2Error checks that the HTTP response contains an expected OAuth2 error
func RequireOAuth2Error(t *testing.T, resp *http.Response, expectedError string) {
t.Helper()
var errorResp OAuth2Error
err := json.NewDecoder(resp.Body).Decode(&errorResp)
require.NoError(t, err, "failed to decode error response")
require.Equal(t, expectedError, errorResp.Error, "unexpected OAuth2 error code")
require.NotEmpty(t, errorResp.ErrorDescription, "missing error description")
}
// PerformTokenExchangeExpectingError performs a token exchange expecting an OAuth2 error
func PerformTokenExchangeExpectingError(t *testing.T, baseURL string, params TokenExchangeParams, expectedError string) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
// Prepare form data
data := url.Values{}
data.Set("grant_type", params.GrantType)
if params.Code != "" {
data.Set("code", params.Code)
}
if params.ClientID != "" {
data.Set("client_id", params.ClientID)
}
if params.ClientSecret != "" {
data.Set("client_secret", params.ClientSecret)
}
if params.CodeVerifier != "" {
data.Set("code_verifier", params.CodeVerifier)
}
if params.RedirectURI != "" {
data.Set("redirect_uri", params.RedirectURI)
}
if params.RefreshToken != "" {
data.Set("refresh_token", params.RefreshToken)
}
if params.Resource != "" {
data.Set("resource", params.Resource)
}
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
require.NoError(t, err, "failed to create token request")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Perform request
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err, "failed to perform token request")
defer resp.Body.Close()
// Should be a 4xx error
require.True(t, resp.StatusCode >= 400 && resp.StatusCode < 500, "expected 4xx status code, got %d", resp.StatusCode)
// Check OAuth2 error
RequireOAuth2Error(t, resp, expectedError)
}
// FetchOAuth2Metadata fetches and returns OAuth2 authorization server metadata
func FetchOAuth2Metadata(t *testing.T, baseURL string) map[string]any {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/.well-known/oauth-authorization-server", nil)
require.NoError(t, err, "failed to create metadata request")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
require.NoError(t, err, "failed to fetch metadata")
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected metadata response status")
var metadata map[string]any
err = json.NewDecoder(resp.Body).Decode(&metadata)
require.NoError(t, err, "failed to decode metadata response")
return metadata
}
// CleanupOAuth2App deletes an OAuth2 app (helper for test cleanup)
func CleanupOAuth2App(t *testing.T, client *codersdk.Client, appID uuid.UUID) {
t.Helper()
ctx := testutil.Context(t, testutil.WaitLong)
err := client.DeleteOAuth2ProviderApp(ctx, appID)
if err != nil {
t.Logf("Warning: failed to cleanup OAuth2 app %s: %v", appID, err)
}
}
@@ -0,0 +1,341 @@
package identityprovidertest_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/identityprovider/identityprovidertest"
)
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Fetch OAuth2 metadata
metadata := identityprovidertest.FetchOAuth2Metadata(t, client.URL.String())
// Verify required metadata fields
require.Contains(t, metadata, "issuer", "missing issuer in metadata")
require.Contains(t, metadata, "authorization_endpoint", "missing authorization_endpoint in metadata")
require.Contains(t, metadata, "token_endpoint", "missing token_endpoint in metadata")
// Verify response types
responseTypes, ok := metadata["response_types_supported"].([]any)
require.True(t, ok, "response_types_supported should be an array")
require.Contains(t, responseTypes, "code", "should support authorization code flow")
// Verify grant types
grantTypes, ok := metadata["grant_types_supported"].([]any)
require.True(t, ok, "grant_types_supported should be an array")
require.Contains(t, grantTypes, "authorization_code", "should support authorization_code grant")
require.Contains(t, grantTypes, "refresh_token", "should support refresh_token grant")
// Verify PKCE support
challengeMethods, ok := metadata["code_challenge_methods_supported"].([]any)
require.True(t, ok, "code_challenge_methods_supported should be an array")
require.Contains(t, challengeMethods, "S256", "should support S256 PKCE method")
// Verify endpoints are proper URLs
authEndpoint, ok := metadata["authorization_endpoint"].(string)
require.True(t, ok, "authorization_endpoint should be a string")
require.Contains(t, authEndpoint, "/oauth2/authorize", "authorization endpoint should be /oauth2/authorize")
tokenEndpoint, ok := metadata["token_endpoint"].(string)
require.True(t, ok, "token_endpoint should be a string")
require.Contains(t, tokenEndpoint, "/oauth2/tokens", "token endpoint should be /oauth2/tokens")
}
func TestOAuth2PKCEFlow(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
// Generate PKCE parameters
codeVerifier, codeChallenge := identityprovidertest.GeneratePKCE(t)
state := identityprovidertest.GenerateState(t)
// Perform authorization
authParams := identityprovidertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256",
}
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token with PKCE
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
CodeVerifier: codeVerifier,
RedirectURI: identityprovidertest.TestRedirectURI,
}
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
require.NotEmpty(t, token.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer")
}
func TestOAuth2InvalidPKCE(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
// Generate PKCE parameters
_, codeChallenge := identityprovidertest.GeneratePKCE(t)
state := identityprovidertest.GenerateState(t)
// Perform authorization
authParams := identityprovidertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI,
State: state,
CodeChallenge: codeChallenge,
CodeChallengeMethod: "S256",
}
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
// Attempt token exchange with wrong code verifier
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
CodeVerifier: identityprovidertest.InvalidCodeVerifier,
RedirectURI: identityprovidertest.TestRedirectURI,
}
identityprovidertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidGrant,
)
}
func TestOAuth2WithoutPKCE(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
state := identityprovidertest.GenerateState(t)
// Perform authorization without PKCE
authParams := identityprovidertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI,
State: state,
}
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token without PKCE
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
RedirectURI: identityprovidertest.TestRedirectURI,
}
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
require.NotEmpty(t, token.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
}
func TestOAuth2ResourceParameter(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
state := identityprovidertest.GenerateState(t)
// Perform authorization with resource parameter
authParams := identityprovidertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI,
State: state,
Resource: identityprovidertest.TestResourceURI,
}
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
require.NotEmpty(t, code, "should receive authorization code")
// Exchange code for token with resource parameter
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
RedirectURI: identityprovidertest.TestRedirectURI,
Resource: identityprovidertest.TestResourceURI,
}
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
require.NotEmpty(t, token.AccessToken, "should receive access token")
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
}
func TestOAuth2TokenRefresh(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
// Create OAuth2 app
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
state := identityprovidertest.GenerateState(t)
// Get initial token
authParams := identityprovidertest.AuthorizeParams{
ClientID: app.ID.String(),
ResponseType: "code",
RedirectURI: identityprovidertest.TestRedirectURI,
State: state,
}
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: code,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
RedirectURI: identityprovidertest.TestRedirectURI,
}
initialToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token")
// Use refresh token to get new access token
refreshParams := identityprovidertest.TokenExchangeParams{
GrantType: "refresh_token",
RefreshToken: initialToken.RefreshToken,
ClientID: app.ID.String(),
ClientSecret: clientSecret,
}
refreshedToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams)
require.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token")
require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different")
}
func TestOAuth2ErrorResponses(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
})
_ = coderdtest.CreateFirstUser(t, client)
t.Run("InvalidClient", func(t *testing.T) {
t.Parallel()
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
Code: "invalid-code",
ClientID: "non-existent-client",
ClientSecret: "invalid-secret",
}
identityprovidertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidClient,
)
})
t.Run("InvalidGrantType", func(t *testing.T) {
t.Parallel()
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "invalid_grant_type",
ClientID: app.ID.String(),
ClientSecret: clientSecret,
}
identityprovidertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.UnsupportedGrantType,
)
})
t.Run("MissingCode", func(t *testing.T) {
t.Parallel()
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
t.Cleanup(func() {
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
})
tokenParams := identityprovidertest.TokenExchangeParams{
GrantType: "authorization_code",
ClientID: app.ID.String(),
ClientSecret: clientSecret,
}
identityprovidertest.PerformTokenExchangeExpectingError(
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidRequest,
)
})
}
+5 -71
View File
@@ -4,9 +4,7 @@ import (
"net/http"
"net/url"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
)
@@ -15,81 +13,20 @@ import (
func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
origin := r.Header.Get(httpmw.OriginHeader)
originU, err := url.Parse(origin)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid origin header.",
Detail: err.Error(),
})
return
}
referer := r.Referer()
refererU, err := url.Parse(referer)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid referer header.",
Detail: err.Error(),
})
return
}
app := httpmw.OAuth2ProviderApp(r)
ua := httpmw.UserAuthorization(r.Context())
// url.Parse() allows empty URLs, which is fine because the origin is not
// always set by browsers (or other tools like cURL). If the origin does
// exist, we will make sure it matches. We require `referer` to be set at
// a minimum in order to detect whether "allow" has been pressed, however.
cameFromSelf := (origin == "" || originU.Hostname() == accessURL.Hostname()) &&
refererU.Hostname() == accessURL.Hostname() &&
refererU.Path == "/oauth2/authorize"
// If we were redirected here from this same page it means the user
// pressed the allow button so defer to the authorize handler which
// generates the code, otherwise show the HTML allow page.
// TODO: Skip this step if the user has already clicked allow before, and
// we can just reuse the token.
if cameFromSelf {
// If this is a POST request, it means the user clicked the "Allow" button
// on the consent form. Process the authorization.
if r.Method == http.MethodPost {
next.ServeHTTP(rw, r)
return
}
// For GET requests, show the authorization consent page
// TODO: For now only browser-based auth flow is officially supported but
// in a future PR we should support a cURL-based flow where we output text
// instead of HTML.
if r.URL.Query().Get("redirected") != "" {
// When the user first comes into the page, referer might be blank which
// is OK. But if they click "allow" and their browser has *still* not
// sent the referer header, we have no way of telling whether they
// actually clicked the button. "Redirected" means they *might* have
// pressed it, but it could also mean an app added it for them as part
// of their redirect, so we cannot use it as a replacement for referer
// and the best we can do is error.
if referer == "" {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
HideStatus: false,
Title: "Referer header missing",
Description: "We cannot continue authorization because your client has not sent the referer header.",
RetryEnabled: false,
DashboardURL: accessURL.String(),
Warnings: nil,
})
return
}
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
HideStatus: false,
Title: "Oauth Redirect Loop",
Description: "Oauth redirect loop detected.",
RetryEnabled: false,
DashboardURL: accessURL.String(),
Warnings: nil,
})
return
}
callbackURL, err := url.Parse(app.CallbackURL)
if err != nil {
@@ -133,10 +70,7 @@ func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler {
cancelQuery.Add("error", "access_denied")
cancel.RawQuery = cancelQuery.Encode()
redirect := r.URL
vals := redirect.Query()
vals.Add("redirected", "true") // For loop detection.
r.URL.RawQuery = vals.Encode()
// Render the consent page with the current URL (no need to add redirected parameter)
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
AppIcon: app.Icon,
AppName: app.Name,
+20
View File
@@ -0,0 +1,20 @@
package identityprovider
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
)
// VerifyPKCE verifies that the code_verifier matches the code_challenge
// using the S256 method as specified in RFC 7636.
func VerifyPKCE(challenge, verifier string) bool {
if challenge == "" || verifier == "" {
return false
}
// S256: BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
h := sha256.Sum256([]byte(verifier))
computed := base64.RawURLEncoding.EncodeToString(h[:])
return subtle.ConstantTimeCompare([]byte(challenge), []byte(computed)) == 1
}
+77
View File
@@ -0,0 +1,77 @@
package identityprovider_test
import (
"crypto/sha256"
"encoding/base64"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/identityprovider"
)
func TestVerifyPKCE(t *testing.T) {
t.Parallel()
tests := []struct {
name string
verifier string
challenge string
expectValid bool
}{
{
name: "ValidPKCE",
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
expectValid: true,
},
{
name: "InvalidPKCE",
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
challenge: "wrong_challenge",
expectValid: false,
},
{
name: "EmptyChallenge",
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
challenge: "",
expectValid: false,
},
{
name: "EmptyVerifier",
verifier: "",
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
expectValid: false,
},
{
name: "BothEmpty",
verifier: "",
challenge: "",
expectValid: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := identityprovider.VerifyPKCE(tt.challenge, tt.verifier)
require.Equal(t, tt.expectValid, result)
})
}
}
func TestPKCES256Generation(t *testing.T) {
t.Parallel()
// Test that we can generate a valid S256 challenge from a verifier
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// Generate challenge using S256 method
h := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(h[:])
require.Equal(t, expectedChallenge, challenge)
require.True(t, identityprovider.VerifyPKCE(challenge, verifier))
}
+58 -16
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/google/uuid"
"golang.org/x/oauth2"
@@ -30,6 +32,8 @@ var (
errBadCode = xerrors.New("Invalid code")
// errBadToken means the user provided a bad token.
errBadToken = xerrors.New("Invalid token")
// errInvalidPKCE means the PKCE verification failed.
errInvalidPKCE = xerrors.New("invalid code_verifier")
)
type tokenParams struct {
@@ -39,6 +43,8 @@ type tokenParams struct {
grantType codersdk.OAuth2ProviderGrantType
redirectURL *url.URL
refreshToken string
codeVerifier string // PKCE verifier
resource string // RFC 8707 resource for token binding
}
func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) {
@@ -65,6 +71,8 @@ func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []c
grantType: grantType,
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
refreshToken: p.String(vals, "", "refresh_token"),
codeVerifier: p.String(vals, "", "code_verifier"),
resource: p.String(vals, "", "resource"),
}
p.ErrorExcessParams(vals)
@@ -94,11 +102,25 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
params, validationErrs, err := extractTokenParams(r, callbackURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query params.",
Detail: err.Error(),
Validations: validationErrs,
})
// Check for specific validation errors in priority order
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == "grant_type"
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", "The grant type is missing or unsupported")
return
}
// Check for missing required parameters for authorization_code grant
for _, field := range []string{"code", "client_id", "client_secret"} {
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
return validationError.Field == field
}) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", fmt.Sprintf("Missing required parameter: %s", field))
return
}
}
// Generic invalid request for other validation errors
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "The request is missing required parameters or is otherwise malformed")
return
}
@@ -111,23 +133,29 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, params)
default:
// Grant types are validated by the parser, so getting through here means
// the developer added a type but forgot to add a case here.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unhandled grant type.",
Detail: fmt.Sprintf("Grant type %q is unhandled", params.grantType),
})
// This should handle truly invalid grant types
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", fmt.Sprintf("The grant type %q is not supported", params.grantType))
return
}
if errors.Is(err, errBadCode) || errors.Is(err, errBadSecret) {
httpapi.Write(r.Context(), rw, http.StatusUnauthorized, codersdk.Response{
Message: err.Error(),
})
if errors.Is(err, errBadSecret) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
return
}
if errors.Is(err, errBadCode) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The authorization code is invalid or expired")
return
}
if errors.Is(err, errInvalidPKCE) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The PKCE code verifier is invalid")
return
}
if errors.Is(err, errBadToken) {
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The refresh token is invalid or expired")
return
}
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to exchange token",
Detail: err.Error(),
})
@@ -188,6 +216,16 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
return oauth2.Token{}, errBadCode
}
// Verify PKCE challenge if present
if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" {
if params.codeVerifier == "" {
return oauth2.Token{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, params.codeVerifier) {
return oauth2.Token{}, errInvalidPKCE
}
}
// Generate a refresh token.
refreshToken, err := GenerateSecret()
if err != nil {
@@ -247,6 +285,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
RefreshHash: []byte(refreshToken.Hashed),
AppSecretID: dbSecret.ID,
APIKeyID: newKey.ID,
Audience: dbCode.ResourceUri,
})
if err != nil {
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
@@ -262,6 +301,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
TokenType: "Bearer",
RefreshToken: refreshToken.Formatted,
Expiry: key.ExpiresAt,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
}, nil
}
@@ -345,6 +385,7 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
RefreshHash: []byte(refreshToken.Hashed),
AppSecretID: dbToken.AppSecretID,
APIKeyID: newKey.ID,
Audience: dbToken.Audience,
})
if err != nil {
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
@@ -360,5 +401,6 @@ func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAut
TokenType: "Bearer",
RefreshToken: refreshToken.Formatted,
Expiry: key.ExpiresAt,
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
}, nil
}
+66 -16
View File
@@ -1,6 +1,7 @@
package coderd
import (
"database/sql"
"fmt"
"net/http"
@@ -114,12 +115,21 @@ func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
return
}
app, err := api.Database.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
ID: uuid.New(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
RedirectUris: []string{},
ClientType: sql.NullString{
String: "confidential",
Valid: true,
},
DynamicallyRegistered: sql.NullBool{
Bool: false,
Valid: true,
},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -161,11 +171,14 @@ func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
return
}
app, err := api.Database.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
ID: app.ID,
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: req.CallbackURL,
RedirectUris: app.RedirectUris, // Keep existing value
ClientType: app.ClientType, // Keep existing value
DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -327,8 +340,8 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re
rw.WriteHeader(http.StatusNoContent)
}
// @Summary OAuth2 authorization request.
// @ID oauth2-authorization-request
// @Summary OAuth2 authorization request (GET - show authorization page).
// @ID oauth2-authorization-request-get
// @Security CoderSessionToken
// @Tags Enterprise
// @Param client_id query string true "Client ID"
@@ -336,10 +349,25 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re
// @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type"
// @Param redirect_uri query string false "Redirect here after authorization"
// @Param scope query string false "Token scopes (currently ignored)"
// @Success 302
// @Router /oauth2/authorize [post]
// @Success 200 "Returns HTML authorization page"
// @Router /oauth2/authorize [get]
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
return identityprovider.Authorize(api.Database, api.AccessURL)
return identityprovider.ShowAuthorizePage(api.Database, api.AccessURL)
}
// @Summary OAuth2 authorization request (POST - process authorization).
// @ID oauth2-authorization-request-post
// @Security CoderSessionToken
// @Tags Enterprise
// @Param client_id query string true "Client ID"
// @Param state query string true "A random unguessable string"
// @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type"
// @Param redirect_uri query string false "Redirect here after authorization"
// @Param scope query string false "Token scopes (currently ignored)"
// @Success 302 "Returns redirect with authorization code"
// @Router /oauth2/authorize [post]
func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
return identityprovider.ProcessAuthorize(api.Database, api.AccessURL)
}
// @Summary OAuth2 token exchange.
@@ -367,3 +395,25 @@ func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
return identityprovider.RevokeApp(api.Database)
}
// @Summary OAuth2 authorization server metadata.
// @ID oauth2-authorization-server-metadata
// @Produce json
// @Tags Enterprise
// @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata
// @Router /.well-known/oauth-authorization-server [get]
func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
metadata := codersdk.OAuth2AuthorizationServerMetadata{
Issuer: api.AccessURL.String(),
AuthorizationEndpoint: api.AccessURL.JoinPath("/oauth2/authorize").String(),
TokenEndpoint: api.AccessURL.JoinPath("/oauth2/tokens").String(),
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
CodeChallengeMethodsSupported: []string{"S256"},
// TODO: Implement scope system
ScopesSupported: []string{},
TokenEndpointAuthMethodsSupported: []string{"client_secret_post"},
}
httpapi.Write(ctx, rw, http.StatusOK, metadata)
}
+43
View File
@@ -0,0 +1,43 @@
package coderd_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Get the metadata
resp, err := client.Request(ctx, http.MethodGet, "/.well-known/oauth-authorization-server", nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var metadata codersdk.OAuth2AuthorizationServerMetadata
err = json.NewDecoder(resp.Body).Decode(&metadata)
require.NoError(t, err)
// Verify the metadata
require.NotEmpty(t, metadata.Issuer)
require.NotEmpty(t, metadata.AuthorizationEndpoint)
require.NotEmpty(t, metadata.TokenEndpoint)
require.Contains(t, metadata.ResponseTypesSupported, "code")
require.Contains(t, metadata.GrantTypesSupported, "authorization_code")
require.Contains(t, metadata.GrantTypesSupported, "refresh_token")
require.Contains(t, metadata.CodeChallengeMethodsSupported, "S256")
}
+42 -41
View File
@@ -422,7 +422,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preAuth: func(valid *oauth2.Config) {
valid.ClientID = uuid.NewString()
},
authError: "Resource not found",
authError: "invalid_client",
},
{
name: "TokenInvalidAppID",
@@ -430,7 +430,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientID = uuid.NewString()
},
tokenError: "Resource not found",
tokenError: "invalid_client",
},
{
name: "InvalidPort",
@@ -440,7 +440,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Host = newURL.Hostname() + ":8081"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "WrongAppHost",
@@ -448,7 +448,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preAuth: func(valid *oauth2.Config) {
valid.RedirectURL = apps.NoPort.CallbackURL
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "InvalidHostPrefix",
@@ -458,7 +458,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Host = "prefix" + newURL.Hostname()
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "InvalidHost",
@@ -468,7 +468,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Host = "invalid"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "InvalidHostAndPort",
@@ -478,7 +478,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Host = "invalid:8080"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "InvalidPath",
@@ -488,7 +488,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Path = path.Join("/prepend", newURL.Path)
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "MissingPath",
@@ -498,7 +498,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Path = "/"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
// TODO: This is valid for now, but should it be?
@@ -529,7 +529,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
newURL.Host = "sub." + newURL.Host
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params",
authError: "Invalid query params:",
},
{
name: "NoSecretScheme",
@@ -537,7 +537,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "1234_4321"
},
tokenError: "Invalid client secret",
tokenError: "The client credentials are invalid",
},
{
name: "InvalidSecretScheme",
@@ -545,7 +545,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "notcoder_1234_4321"
},
tokenError: "Invalid client secret",
tokenError: "The client credentials are invalid",
},
{
name: "MissingSecretSecret",
@@ -553,7 +553,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder_1234"
},
tokenError: "Invalid client secret",
tokenError: "The client credentials are invalid",
},
{
name: "MissingSecretPrefix",
@@ -561,7 +561,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder__1234"
},
tokenError: "Invalid client secret",
tokenError: "The client credentials are invalid",
},
{
name: "InvalidSecretPrefix",
@@ -569,7 +569,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = "coder_1234_4321"
},
tokenError: "Invalid client secret",
tokenError: "The client credentials are invalid",
},
{
name: "MissingSecret",
@@ -577,48 +577,48 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
preToken: func(valid *oauth2.Config) {
valid.ClientSecret = ""
},
tokenError: "Invalid query params",
tokenError: "invalid_request",
},
{
name: "NoCodeScheme",
app: apps.Default,
defaultCode: ptr.Ref("1234_4321"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
},
{
name: "InvalidCodeScheme",
app: apps.Default,
defaultCode: ptr.Ref("notcoder_1234_4321"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
},
{
name: "MissingCodeSecret",
app: apps.Default,
defaultCode: ptr.Ref("coder_1234"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
},
{
name: "MissingCodePrefix",
app: apps.Default,
defaultCode: ptr.Ref("coder__1234"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
},
{
name: "InvalidCodePrefix",
app: apps.Default,
defaultCode: ptr.Ref("coder_1234_4321"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
},
{
name: "MissingCode",
app: apps.Default,
defaultCode: ptr.Ref(""),
tokenError: "Invalid query params",
tokenError: "invalid_request",
},
{
name: "InvalidGrantType",
app: apps.Default,
tokenError: "Invalid query params",
tokenError: "unsupported_grant_type",
exchangeMutate: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("grant_type", "foobar"),
},
@@ -626,7 +626,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
{
name: "EmptyGrantType",
app: apps.Default,
tokenError: "Invalid query params",
tokenError: "unsupported_grant_type",
exchangeMutate: []oauth2.AuthCodeOption{
oauth2.SetAuthURLParam("grant_type", ""),
},
@@ -635,7 +635,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
name: "ExpiredCode",
app: apps.Default,
defaultCode: ptr.Ref("coder_prefix_code"),
tokenError: "Invalid code",
tokenError: "The authorization code is invalid or expired",
setup: func(ctx context.Context, client *codersdk.Client, user codersdk.User) error {
// Insert an expired code.
hashedCode, err := userpassword.Hash("prefix_code")
@@ -720,7 +720,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
} else {
require.NoError(t, err)
require.NotEmpty(t, token.AccessToken)
require.True(t, time.Now().After(token.Expiry))
require.True(t, time.Now().Before(token.Expiry))
// Check that the token works.
newClient := codersdk.New(userClient.URL)
@@ -764,37 +764,37 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
name: "NoTokenScheme",
app: apps.Default,
defaultToken: ptr.Ref("1234_4321"),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "InvalidTokenScheme",
app: apps.Default,
defaultToken: ptr.Ref("notcoder_1234_4321"),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "MissingTokenSecret",
app: apps.Default,
defaultToken: ptr.Ref("coder_1234"),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "MissingTokenPrefix",
app: apps.Default,
defaultToken: ptr.Ref("coder__1234"),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "InvalidTokenPrefix",
app: apps.Default,
defaultToken: ptr.Ref("coder_1234_4321"),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "Expired",
app: apps.Default,
expires: time.Now().Add(time.Minute * -1),
error: "Invalid token",
error: "The refresh token is invalid or expired",
},
{
name: "OK",
@@ -1085,20 +1085,21 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su
func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2.Config) (string, error) {
state := uuid.NewString()
authURL := cfg.AuthCodeURL(state)
// Make a POST request to simulate clicking "Allow" on the authorization page
// This bypasses the HTML consent page and directly processes the authorization
return oidctest.OAuth2GetCode(
cfg.AuthCodeURL(state),
authURL,
func(req *http.Request) (*http.Response, error) {
// TODO: Would be better if client had a .Do() method.
// TODO: Is this the best way to handle redirects?
// Change to POST to simulate the form submission
req.Method = http.MethodPost
// Prevent automatic redirect following
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
return client.Request(ctx, req.Method, req.URL.String(), nil, func(req *http.Request) {
// Set the referer so the request bypasses the HTML page (normally you
// have to click "allow" first, and the way we detect that is using the
// referer header).
req.Header.Set("Referer", req.URL.String())
})
return client.Request(ctx, req.Method, req.URL.String(), nil)
},
)
}
+13
View File
@@ -231,3 +231,16 @@ func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) e
type OAuth2DeviceFlowCallbackResponse struct {
RedirectURL string `json:"redirect_url"`
}
// OAuth2AuthorizationServerMetadata represents RFC 8414 OAuth 2.0 Authorization Server Metadata
type OAuth2AuthorizationServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported,omitempty"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"`
}
+1 -1
View File
@@ -21,7 +21,7 @@ We track the following resources:
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| NotificationTemplate<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>actions</td><td>true</td></tr><tr><td>body_template</td><td>true</td></tr><tr><td>enabled_by_default</td><td>true</td></tr><tr><td>group</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>kind</td><td>true</td></tr><tr><td>method</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>title_template</td><td>true</td></tr></tbody></table> |
| NotificationsSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>id</td><td>false</td></tr><tr><td>notifier_paused</td><td>true</td></tr></tbody></table> |
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| OAuth2ProviderApp<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>callback_url</td><td>true</td></tr><tr><td>client_type</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>dynamically_registered</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>redirect_uris</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| OAuth2ProviderAppSecret<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>app_id</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>display_secret</td><td>false</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>secret_prefix</td><td>false</td></tr></tbody></table> |
| Organization<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>is_default</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr></tbody></table> |
| OrganizationSyncSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody> | <tr><td>assign_default</td><td>true</td></tr><tr><td>field</td><td>true</td></tr><tr><td>mapping</td><td>true</td></tr></tbody></table> |
+86 -4
View File
@@ -1,5 +1,51 @@
# Enterprise
## OAuth2 authorization server metadata
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/.well-known/oauth-authorization-server \
-H 'Accept: application/json'
```
`GET /.well-known/oauth-authorization-server`
### Example responses
> 200 Response
```json
{
"authorization_endpoint": "string",
"code_challenge_methods_supported": [
"string"
],
"grant_types_supported": [
"string"
],
"issuer": "string",
"registration_endpoint": "string",
"response_types_supported": [
"string"
],
"scopes_supported": [
"string"
],
"token_endpoint": "string",
"token_endpoint_auth_methods_supported": [
"string"
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OAuth2AuthorizationServerMetadata](schemas.md#codersdkoauth2authorizationservermetadata) |
## Get appearance
### Code samples
@@ -967,7 +1013,43 @@ curl -X DELETE http://coder-server:8080/api/v2/oauth2-provider/apps/{app}/secret
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## OAuth2 authorization request
## OAuth2 authorization request (GET - show authorization page)
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/oauth2/authorize?client_id=string&state=string&response_type=code \
-H 'Coder-Session-Token: API_KEY'
```
`GET /oauth2/authorize`
### Parameters
| Name | In | Type | Required | Description |
|-----------------|-------|--------|----------|-----------------------------------|
| `client_id` | query | string | true | Client ID |
| `state` | query | string | true | A random unguessable string |
| `response_type` | query | string | true | Response type |
| `redirect_uri` | query | string | false | Redirect here after authorization |
| `scope` | query | string | false | Token scopes (currently ignored) |
#### Enumerated Values
| Parameter | Value |
|-----------------|--------|
| `response_type` | `code` |
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|---------------------------------|--------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Returns HTML authorization page | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## OAuth2 authorization request (POST - process authorization)
### Code samples
@@ -997,9 +1079,9 @@ curl -X POST http://coder-server:8080/api/v2/oauth2/authorize?client_id=string&s
### Responses
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------|-------------|--------|
| 302 | [Found](https://tools.ietf.org/html/rfc7231#section-6.4.3) | Found | |
| Status | Meaning | Description | Schema |
|--------|------------------------------------------------------------|------------------------------------------|--------|
| 302 | [Found](https://tools.ietf.org/html/rfc7231#section-6.4.3) | Returns redirect with authorization code | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+40
View File
@@ -4182,6 +4182,46 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `device_authorization` | string | false | | Device authorization is optional. |
| `token` | string | false | | |
## codersdk.OAuth2AuthorizationServerMetadata
```json
{
"authorization_endpoint": "string",
"code_challenge_methods_supported": [
"string"
],
"grant_types_supported": [
"string"
],
"issuer": "string",
"registration_endpoint": "string",
"response_types_supported": [
"string"
],
"scopes_supported": [
"string"
],
"token_endpoint": "string",
"token_endpoint_auth_methods_supported": [
"string"
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-----------------------------------------|-----------------|----------|--------------|-------------|
| `authorization_endpoint` | string | false | | |
| `code_challenge_methods_supported` | array of string | false | | |
| `grant_types_supported` | array of string | false | | |
| `issuer` | string | false | | |
| `registration_endpoint` | string | false | | |
| `response_types_supported` | array of string | false | | |
| `scopes_supported` | array of string | false | | |
| `token_endpoint` | string | false | | |
| `token_endpoint_auth_methods_supported` | array of string | false | | |
## codersdk.OAuth2Config
```json
+9 -6
View File
@@ -262,12 +262,15 @@ var auditableResourcesTypes = map[any]map[string]Action{
"version": ActionTrack,
},
&database.OAuth2ProviderApp{}: {
"id": ActionIgnore,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
"name": ActionTrack,
"icon": ActionTrack,
"callback_url": ActionTrack,
"id": ActionIgnore,
"created_at": ActionIgnore,
"updated_at": ActionIgnore,
"name": ActionTrack,
"icon": ActionTrack,
"callback_url": ActionTrack,
"redirect_uris": ActionTrack,
"client_type": ActionTrack,
"dynamically_registered": ActionTrack,
},
&database.OAuth2ProviderAppSecret{}: {
"id": ActionIgnore,
+1
View File
@@ -132,6 +132,7 @@
gnugrep
gnutar
unstablePkgs.go_1_24
gofumpt
go-migrate
(pinnedPkgs.golangci-lint)
gopls
+150
View File
@@ -0,0 +1,150 @@
# OAuth2 Test Scripts
This directory contains test scripts for the MCP OAuth2 implementation in Coder.
## Prerequisites
1. Start Coder in development mode:
```bash
./scripts/develop.sh
```
2. Login to get a session token:
```bash
./scripts/coder-dev.sh login
```
## Scripts
### `test-mcp-oauth2.sh`
Complete automated test suite that verifies all OAuth2 functionality:
- Metadata endpoint
- PKCE flow
- Resource parameter support
- Token refresh
- Error handling
Usage:
```bash
chmod +x ./scripts/oauth2/test-mcp-oauth2.sh
./scripts/oauth2/test-mcp-oauth2.sh
```
### `setup-test-app.sh`
Creates a test OAuth2 application and outputs environment variables.
Usage:
```bash
eval $(./scripts/oauth2/setup-test-app.sh)
echo "Client ID: $CLIENT_ID"
```
### `cleanup-test-app.sh`
Deletes a test OAuth2 application.
Usage:
```bash
./scripts/oauth2/cleanup-test-app.sh $CLIENT_ID
# Or if CLIENT_ID is set as environment variable:
./scripts/oauth2/cleanup-test-app.sh
```
### `generate-pkce.sh`
Generates PKCE code verifier and challenge for manual testing.
Usage:
```bash
./scripts/oauth2/generate-pkce.sh
```
### `test-manual-flow.sh`
Launches a local Go web server to test the OAuth2 flow interactively. The server automatically handles the OAuth2 callback and token exchange, providing a user-friendly web interface with results.
Usage:
```bash
# First set up an app
eval $(./scripts/oauth2/setup-test-app.sh)
# Then run the test server
./scripts/oauth2/test-manual-flow.sh
```
Features:
- Starts a local web server on port 9876
- Automatically captures the authorization code
- Performs token exchange without manual intervention
- Displays results in a clean web interface
- Shows example API calls you can make with the token
### `oauth2-test-server.go`
A Go web server that handles OAuth2 callbacks and token exchange. Used internally by `test-manual-flow.sh` but can also be run standalone:
```bash
export CLIENT_ID="your-client-id"
export CLIENT_SECRET="your-client-secret"
export CODE_VERIFIER="your-code-verifier"
export STATE="your-state"
go run ./scripts/oauth2/oauth2-test-server.go
```
## Example Workflow
1. **Run automated tests:**
```bash
./scripts/oauth2/test-mcp-oauth2.sh
```
2. **Interactive browser testing:**
```bash
# Create app
eval $(./scripts/oauth2/setup-test-app.sh)
# Run the test server (opens in browser automatically)
./scripts/oauth2/test-manual-flow.sh
# - Opens authorization URL in terminal
# - Handles callback automatically
# - Shows token exchange results
# Clean up when done
./scripts/oauth2/cleanup-test-app.sh
```
3. **Generate PKCE for custom testing:**
```bash
./scripts/oauth2/generate-pkce.sh
# Use the generated values in your own curl commands
```
## Environment Variables
All scripts respect these environment variables:
- `SESSION_TOKEN`: Coder session token (auto-read from `.coderv2/session`)
- `BASE_URL`: Coder server URL (default: `http://localhost:3000`)
- `CLIENT_ID`: OAuth2 client ID
- `CLIENT_SECRET`: OAuth2 client secret
## OAuth2 Endpoints
- Metadata: `GET /.well-known/oauth-authorization-server`
- Authorization: `GET/POST /oauth2/authorize`
- Token: `POST /oauth2/tokens`
- Apps API: `/api/v2/oauth2-provider/apps`
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -e
# Cleanup OAuth2 test app
# Usage: ./cleanup-test-app.sh [CLIENT_ID]
CLIENT_ID="${1:-$CLIENT_ID}"
SESSION_TOKEN="${SESSION_TOKEN:-$(cat ./.coderv2/session 2>/dev/null || echo '')}"
BASE_URL="${BASE_URL:-http://localhost:3000}"
if [ -z "$CLIENT_ID" ]; then
echo "ERROR: CLIENT_ID must be provided as argument or environment variable"
echo "Usage: ./cleanup-test-app.sh <CLIENT_ID>"
echo "Or set CLIENT_ID environment variable"
exit 1
fi
if [ -z "$SESSION_TOKEN" ]; then
echo "ERROR: SESSION_TOKEN must be set or ./.coderv2/session must exist"
exit 1
fi
AUTH_HEADER="Coder-Session-Token: $SESSION_TOKEN"
echo "Deleting OAuth2 app: $CLIENT_ID"
RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$CLIENT_ID" \
-H "$AUTH_HEADER")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
BODY=$(echo "$RESPONSE" | head -n -1)
if [ "$HTTP_CODE" = "204" ]; then
echo "✓ Successfully deleted OAuth2 app: $CLIENT_ID"
else
echo "✗ Failed to delete OAuth2 app: $CLIENT_ID"
echo "HTTP $HTTP_CODE"
if [ -n "$BODY" ]; then
echo "$BODY" | jq . 2>/dev/null || echo "$BODY"
fi
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Generate PKCE code verifier and challenge for OAuth2 flow
# Usage: ./generate-pkce.sh
# Generate code verifier (43-128 characters, URL-safe)
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c -43)
# Generate code challenge (S256 method)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
echo "Code Verifier: $CODE_VERIFIER"
echo "Code Challenge: $CODE_CHALLENGE"
# Export as environment variables for use in other scripts
export CODE_VERIFIER
export CODE_CHALLENGE
echo ""
echo "Environment variables set:"
echo " CODE_VERIFIER=\"$CODE_VERIFIER\""
echo " CODE_CHALLENGE=\"$CODE_CHALLENGE\""
echo ""
echo "Usage in curl:"
echo " curl \"...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256\""
echo " curl -d \"code_verifier=$CODE_VERIFIER\" ..."
+292
View File
@@ -0,0 +1,292 @@
package main
import (
"cmp"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"golang.org/x/xerrors"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
type Config struct {
ClientID string
ClientSecret string
CodeVerifier string
State string
BaseURL string
RedirectURI string
}
type ServerOptions struct {
KeepRunning bool
}
func main() {
var serverOpts ServerOptions
flag.BoolVar(&serverOpts.KeepRunning, "keep-running", false, "Keep server running after successful authorization")
flag.Parse()
config := &Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
CodeVerifier: os.Getenv("CODE_VERIFIER"),
State: os.Getenv("STATE"),
BaseURL: cmp.Or(os.Getenv("BASE_URL"), "http://localhost:3000"),
RedirectURI: "http://localhost:9876/callback",
}
if config.ClientID == "" || config.ClientSecret == "" {
log.Fatal("CLIENT_ID and CLIENT_SECRET must be set. Run: eval $(./setup-test-app.sh) first")
}
if config.CodeVerifier == "" || config.State == "" {
log.Fatal("CODE_VERIFIER and STATE must be set. Run test-manual-flow.sh to get these values")
}
var server *http.Server
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Test Server</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.status { padding: 20px; margin: 20px 0; border-radius: 5px; }
.waiting { background: #fff3cd; color: #856404; }
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
pre { background: #f5f5f5; padding: 15px; overflow-x: auto; }
a { color: #0066cc; }
</style>
</head>
<body>
<h1>OAuth2 Test Server</h1>
<div class="status waiting">
<h2>Waiting for OAuth2 callback...</h2>
<p>Please authorize the application in your browser.</p>
<p>Listening on: <code>%s</code></p>
</div>
</body>
</html>`, config.RedirectURI)
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, html)
})
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errorParam := r.URL.Query().Get("error")
errorDesc := r.URL.Query().Get("error_description")
if errorParam != "" {
showError(w, fmt.Sprintf("Authorization failed: %s - %s", errorParam, errorDesc))
return
}
if code == "" {
showError(w, "No authorization code received")
return
}
if state != config.State {
showError(w, fmt.Sprintf("State mismatch. Expected: %s, Got: %s", config.State, state))
return
}
log.Printf("Received authorization code: %s", code)
log.Printf("Exchanging code for token...")
tokenResp, err := exchangeToken(config, code)
if err != nil {
showError(w, fmt.Sprintf("Token exchange failed: %v", err))
return
}
showSuccess(w, code, tokenResp, serverOpts)
if !serverOpts.KeepRunning {
// Schedule graceful shutdown after giving time for the response to be sent
go func() {
time.Sleep(2 * time.Second)
cancel()
}()
}
})
server = &http.Server{
Addr: ":9876",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("Starting OAuth2 test server on http://localhost:9876")
log.Printf("Waiting for callback at %s", config.RedirectURI)
if !serverOpts.KeepRunning {
log.Printf("Server will shut down automatically after successful authorization")
}
// Start server in a goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for context cancellation
<-ctx.Done()
// Graceful shutdown
log.Printf("Shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
log.Printf("Server stopped successfully")
}
func exchangeToken(config *Config, code string) (*TokenResponse, error) {
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", config.ClientID)
data.Set("client_secret", config.ClientSecret)
data.Set("code_verifier", config.CodeVerifier)
data.Set("redirect_uri", config.RedirectURI)
ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, "POST", config.BaseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, xerrors.Errorf("failed to decode response: %w", err)
}
if tokenResp.Error != "" {
return nil, xerrors.Errorf("token error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
return &tokenResp, nil
}
func showError(w http.ResponseWriter, message string) {
log.Printf("ERROR: %s", message)
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Test - Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.status { padding: 20px; margin: 20px 0; border-radius: 5px; }
.error { background: #f8d7da; color: #721c24; }
pre { background: #f5f5f5; padding: 15px; overflow-x: auto; }
</style>
</head>
<body>
<h1>OAuth2 Test Server - Error</h1>
<div class="status error">
<h2> Error</h2>
<p>%s</p>
</div>
<p>Check the server logs for more details.</p>
</body>
</html>`, message)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprint(w, html)
}
func showSuccess(w http.ResponseWriter, code string, tokenResp *TokenResponse, opts ServerOptions) {
log.Printf("SUCCESS: Token exchange completed")
tokenJSON, _ := json.MarshalIndent(tokenResp, "", " ")
serverNote := "The server will shut down automatically in a few seconds."
if opts.KeepRunning {
serverNote = "The server will continue running. Press Ctrl+C in the terminal to stop it."
}
html := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Test - Success</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.status { padding: 20px; margin: 20px 0; border-radius: 5px; }
.success { background: #d4edda; color: #155724; }
pre { background: #f5f5f5; padding: 15px; overflow-x: auto; }
.section { margin: 20px 0; }
code { background: #e9ecef; padding: 2px 4px; border-radius: 3px; }
</style>
</head>
<body>
<h1>OAuth2 Test Server - Success</h1>
<div class="status success">
<h2>Authorization Successful!</h2>
<p>Successfully exchanged authorization code for tokens.</p>
</div>
<div class="section">
<h3>Authorization Code</h3>
<pre>%s</pre>
</div>
<div class="section">
<h3>Token Response</h3>
<pre>%s</pre>
</div>
<div class="section">
<h3>Next Steps</h3>
<p>You can now use the access token to make API requests:</p>
<pre>curl -H "Coder-Session-Token: %s" %s/api/v2/users/me | jq .</pre>
</div>
<div class="section">
<p><strong>Note:</strong> %s</p>
</div>
</body>
</html>`, code, string(tokenJSON), tokenResp.AccessToken, cmp.Or(os.Getenv("BASE_URL"), "http://localhost:3000"), serverNote)
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, html)
}
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
set -e
# Setup OAuth2 test app and return credentials
# Usage: eval $(./setup-test-app.sh)
SESSION_TOKEN="${SESSION_TOKEN:-$(tr -d '\n' <./.coderv2/session || echo '')}"
BASE_URL="${BASE_URL:-http://localhost:3000}"
if [ -z "$SESSION_TOKEN" ]; then
echo "ERROR: SESSION_TOKEN must be set or ./.coderv2/session must exist" >&2
echo "Run: ./scripts/coder-dev.sh login" >&2
exit 1
fi
AUTH_HEADER="Coder-Session-Token: $SESSION_TOKEN"
# Create OAuth2 App
APP_NAME="test-mcp-$(date +%s)"
APP_RESPONSE=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps" \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$APP_NAME\",
\"callback_url\": \"http://localhost:9876/callback\"
}")
CLIENT_ID=$(echo "$APP_RESPONSE" | jq -r '.id')
if [ "$CLIENT_ID" = "null" ] || [ -z "$CLIENT_ID" ]; then
echo "ERROR: Failed to create OAuth2 app" >&2
echo "$APP_RESPONSE" | jq . >&2
exit 1
fi
# Create Client Secret
SECRET_RESPONSE=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$CLIENT_ID/secrets" \
-H "$AUTH_HEADER")
CLIENT_SECRET=$(echo "$SECRET_RESPONSE" | jq -r '.client_secret_full')
if [ "$CLIENT_SECRET" = "null" ] || [ -z "$CLIENT_SECRET" ]; then
echo "ERROR: Failed to create client secret" >&2
echo "$SECRET_RESPONSE" | jq . >&2
exit 1
fi
# Output environment variable exports
echo "export CLIENT_ID=\"$CLIENT_ID\""
echo "export CLIENT_SECRET=\"$CLIENT_SECRET\""
echo "export APP_NAME=\"$APP_NAME\""
echo "export BASE_URL=\"$BASE_URL\""
echo "export SESSION_TOKEN=\"$SESSION_TOKEN\""
echo "# OAuth2 app created successfully:" >&2
echo "# App Name: $APP_NAME" >&2
echo "# Client ID: $CLIENT_ID" >&2
echo "# Run: eval \$(./setup-test-app.sh) to set environment variables" >&2
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
set -e
# Manual OAuth2 flow test with automatic callback handling
# Usage: ./test-manual-flow.sh
SESSION_TOKEN="${SESSION_TOKEN:-$(cat ./.coderv2/session 2>/dev/null || echo '')}"
BASE_URL="${BASE_URL:-http://localhost:3000}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Cleanup function
cleanup() {
if [ -n "$SERVER_PID" ]; then
echo -e "\n${YELLOW}Stopping OAuth2 test server...${NC}"
kill "$SERVER_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Check if app credentials are set
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then
echo -e "${RED}ERROR: CLIENT_ID and CLIENT_SECRET must be set${NC}"
echo "Run: eval \$(./setup-test-app.sh) first"
exit 1
fi
# Check if Go is installed
if ! command -v go &>/dev/null; then
echo -e "${RED}ERROR: Go is not installed${NC}"
echo "Please install Go to use the OAuth2 test server"
exit 1
fi
# Generate PKCE parameters
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c -43)
export CODE_VERIFIER
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
export CODE_CHALLENGE
# Generate state parameter
STATE=$(openssl rand -hex 16)
export STATE
# Export required environment variables
export CLIENT_ID
export CLIENT_SECRET
export BASE_URL
# Start the OAuth2 test server
echo -e "${YELLOW}Starting OAuth2 test server on http://localhost:9876${NC}"
go run "$SCRIPT_DIR/oauth2-test-server.go" &
SERVER_PID=$!
# Wait for server to start
sleep 1
# Build authorization URL
AUTH_URL="$BASE_URL/oauth2/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost:9876/callback&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
echo ""
echo -e "${GREEN}=== Manual OAuth2 Flow Test ===${NC}"
echo ""
echo "1. Open this URL in your browser:"
echo -e "${YELLOW}$AUTH_URL${NC}"
echo ""
echo "2. Log in if required, then click 'Allow' to authorize the application"
echo ""
echo "3. You'll be automatically redirected to the test server"
echo " The server will handle the token exchange and display the results"
echo ""
echo -e "${YELLOW}Waiting for OAuth2 callback...${NC}"
echo "Press Ctrl+C to cancel"
echo ""
# Wait for the server process
wait $SERVER_PID
+180
View File
@@ -0,0 +1,180 @@
#!/bin/bash
set -euo pipefail
# Configuration
SESSION_TOKEN="${SESSION_TOKEN:-$(cat ./.coderv2/session 2>/dev/null || echo '')}"
BASE_URL="${BASE_URL:-http://localhost:3000}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Check prerequisites
if [ -z "$SESSION_TOKEN" ]; then
echo -e "${RED}ERROR: SESSION_TOKEN must be set or ./.coderv2/session must exist${NC}"
echo "Usage: SESSION_TOKEN=xxx ./test-mcp-oauth2.sh"
echo "Or run: ./scripts/coder-dev.sh login"
exit 1
fi
# Use session token for authentication
AUTH_HEADER="Coder-Session-Token: $SESSION_TOKEN"
echo -e "${BLUE}=== MCP OAuth2 Phase 1 Complete Test Suite ===${NC}\n"
# Test 1: Metadata endpoint
echo -e "${YELLOW}Test 1: OAuth2 Authorization Server Metadata${NC}"
METADATA=$(curl -s "$BASE_URL/.well-known/oauth-authorization-server")
echo "$METADATA" | jq .
if echo "$METADATA" | jq -e '.authorization_endpoint' >/dev/null; then
echo -e "${GREEN}✓ Metadata endpoint working${NC}\n"
else
echo -e "${RED}✗ Metadata endpoint failed${NC}\n"
exit 1
fi
# Create OAuth2 App
echo -e "${YELLOW}Creating OAuth2 app...${NC}"
APP_NAME="test-mcp-$(date +%s)"
APP_RESPONSE=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps" \
-H "$AUTH_HEADER" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$APP_NAME\",
\"callback_url\": \"http://localhost:9876/callback\"
}")
if ! CLIENT_ID=$(echo "$APP_RESPONSE" | jq -r '.id'); then
echo -e "${RED}Failed to create app:${NC}"
echo "$APP_RESPONSE" | jq .
exit 1
fi
echo -e "${GREEN}✓ Created app: $APP_NAME (ID: $CLIENT_ID)${NC}"
# Create Client Secret
echo -e "${YELLOW}Creating client secret...${NC}"
SECRET_RESPONSE=$(curl -s -X POST "$BASE_URL/api/v2/oauth2-provider/apps/$CLIENT_ID/secrets" \
-H "$AUTH_HEADER")
CLIENT_SECRET=$(echo "$SECRET_RESPONSE" | jq -r '.client_secret_full')
echo -e "${GREEN}✓ Created client secret${NC}\n"
# Test 2: PKCE Flow
echo -e "${YELLOW}Test 2: PKCE Flow${NC}"
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d "=+/" | cut -c -43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d "=" | tr '+/' '-_')
STATE=$(openssl rand -hex 16)
AUTH_URL="$BASE_URL/oauth2/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost:9876/callback&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
REDIRECT_URL=$(curl -s -X POST "$AUTH_URL" \
-H "Coder-Session-Token: $SESSION_TOKEN" \
-w '\n%{redirect_url}' \
-o /dev/null)
CODE=$(echo "$REDIRECT_URL" | grep -oP 'code=\K[^&]+')
if [ -n "$CODE" ]; then
echo -e "${GREEN}✓ Got authorization code with PKCE${NC}"
else
echo -e "${RED}✗ Failed to get authorization code${NC}"
exit 1
fi
# Exchange with PKCE
TOKEN_RESPONSE=$(curl -s -X POST "$BASE_URL/oauth2/tokens" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code_verifier=$CODE_VERIFIER")
if echo "$TOKEN_RESPONSE" | jq -e '.access_token' >/dev/null; then
echo -e "${GREEN}✓ PKCE token exchange successful${NC}\n"
else
echo -e "${RED}✗ PKCE token exchange failed:${NC}"
echo "$TOKEN_RESPONSE" | jq .
exit 1
fi
# Test 3: Invalid PKCE
echo -e "${YELLOW}Test 3: Invalid PKCE (negative test)${NC}"
# Get new code
REDIRECT_URL=$(curl -s -X POST "$AUTH_URL" \
-H "Coder-Session-Token: $SESSION_TOKEN" \
-w '\n%{redirect_url}' \
-o /dev/null)
CODE=$(echo "$REDIRECT_URL" | grep -oP 'code=\K[^&]+')
ERROR_RESPONSE=$(curl -s -X POST "$BASE_URL/oauth2/tokens" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code_verifier=wrong-verifier")
if echo "$ERROR_RESPONSE" | jq -e '.error' >/dev/null; then
echo -e "${GREEN}✓ Invalid PKCE correctly rejected${NC}\n"
else
echo -e "${RED}✗ Invalid PKCE was not rejected${NC}\n"
fi
# Test 4: Resource Parameter
echo -e "${YELLOW}Test 4: Resource Parameter Support${NC}"
RESOURCE="https://api.example.com"
STATE=$(openssl rand -hex 16)
RESOURCE_AUTH_URL="$BASE_URL/oauth2/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost:9876/callback&state=$STATE&resource=$RESOURCE"
REDIRECT_URL=$(curl -s -X POST "$RESOURCE_AUTH_URL" \
-H "Coder-Session-Token: $SESSION_TOKEN" \
-w '\n%{redirect_url}' \
-o /dev/null)
CODE=$(echo "$REDIRECT_URL" | grep -oP 'code=\K[^&]+')
TOKEN_RESPONSE=$(curl -s -X POST "$BASE_URL/oauth2/tokens" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "resource=$RESOURCE")
if echo "$TOKEN_RESPONSE" | jq -e '.access_token' >/dev/null; then
echo -e "${GREEN}✓ Resource parameter flow successful${NC}\n"
else
echo -e "${RED}✗ Resource parameter flow failed${NC}\n"
fi
# Test 5: Token Refresh
echo -e "${YELLOW}Test 5: Token Refresh${NC}"
REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')
REFRESH_RESPONSE=$(curl -s -X POST "$BASE_URL/oauth2/tokens" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET")
if echo "$REFRESH_RESPONSE" | jq -e '.access_token' >/dev/null; then
echo -e "${GREEN}✓ Token refresh successful${NC}\n"
else
echo -e "${RED}✗ Token refresh failed${NC}\n"
fi
# Cleanup
echo -e "${YELLOW}Cleaning up...${NC}"
curl -s -X DELETE "$BASE_URL/api/v2/oauth2-provider/apps/$CLIENT_ID" \
-H "$AUTH_HEADER" >/dev/null
echo -e "${GREEN}✓ Deleted test app${NC}"
echo -e "\n${BLUE}=== All tests completed successfully! ===${NC}"
+13
View File
@@ -1445,6 +1445,19 @@ export interface OAuth2AppEndpoints {
readonly device_authorization: string;
}
// From codersdk/oauth2.go
export interface OAuth2AuthorizationServerMetadata {
readonly issuer: string;
readonly authorization_endpoint: string;
readonly token_endpoint: string;
readonly registration_endpoint?: string;
readonly response_types_supported: readonly string[];
readonly grant_types_supported: readonly string[];
readonly code_challenge_methods_supported: readonly string[];
readonly scopes_supported?: readonly string[];
readonly token_endpoint_auth_methods_supported?: readonly string[];
}
// From codersdk/deployment.go
export interface OAuth2Config {
readonly github: OAuth2GithubConfig;
+3 -1
View File
@@ -160,7 +160,9 @@ links */}}
<span class="user-name">{{ .Username }}</span> account?
</p>
<div class="button-group">
<a class="primary-button" href="{{ .RedirectURI }}">Allow</a>
<form method="POST" style="display: inline;">
<button type="submit" class="primary-button">Allow</button>
</form>
<a href="{{ .CancelURI }}">Cancel</a>
</div>
</div>