From d10ac394632d949d9b139140d11bee6ff85c1dba Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:47:38 +0900 Subject: [PATCH 01/26] feat: add client table schema and queries --- db/query/oidc.sql | 5 ++++- db/schema.sql | 2 +- internal/repository/oidc/db.go | 10 ++++++++++ internal/repository/oidc/oidc.sql.go | 9 +++++++++ internal/repository/oidc/querier.go | 1 + 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/db/query/oidc.sql b/db/query/oidc.sql index 260c637..250a0fe 100644 --- a/db/query/oidc.sql +++ b/db/query/oidc.sql @@ -28,4 +28,7 @@ UPDATE clients SET WHERE client_id = ?; -- name: DeleteClient :exec -DELETE FROM clients WHERE client_id = ?; \ No newline at end of file +DELETE FROM clients WHERE client_id = ?; + +-- name: DeleteAllClients :exec +DELETE FROM clients; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 2639f4f..0a40901 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,6 +1,6 @@ -- OIDC Schema -CREATE TABLE `clients` ( +CREATE TABLE IF NOT EXISTS `clients` ( `client_id` char(36) NOT NULL, `client_secret_hash` varchar(255) NULL, `name` varchar(255) NOT NULL, diff --git a/internal/repository/oidc/db.go b/internal/repository/oidc/db.go index e6fea6b..70cdeb7 100644 --- a/internal/repository/oidc/db.go +++ b/internal/repository/oidc/db.go @@ -27,6 +27,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createClientStmt, err = db.PrepareContext(ctx, createClient); err != nil { return nil, fmt.Errorf("error preparing query CreateClient: %w", err) } + if q.deleteAllClientsStmt, err = db.PrepareContext(ctx, deleteAllClients); err != nil { + return nil, fmt.Errorf("error preparing query DeleteAllClients: %w", err) + } if q.deleteClientStmt, err = db.PrepareContext(ctx, deleteClient); err != nil { return nil, fmt.Errorf("error preparing query DeleteClient: %w", err) } @@ -52,6 +55,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createClientStmt: %w", cerr) } } + if q.deleteAllClientsStmt != nil { + if cerr := q.deleteAllClientsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteAllClientsStmt: %w", cerr) + } + } if q.deleteClientStmt != nil { if cerr := q.deleteClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteClientStmt: %w", cerr) @@ -117,6 +125,7 @@ type Queries struct { db DBTX tx *sql.Tx createClientStmt *sql.Stmt + deleteAllClientsStmt *sql.Stmt deleteClientStmt *sql.Stmt getClientStmt *sql.Stmt listClientsStmt *sql.Stmt @@ -129,6 +138,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { db: tx, tx: tx, createClientStmt: q.createClientStmt, + deleteAllClientsStmt: q.deleteAllClientsStmt, deleteClientStmt: q.deleteClientStmt, getClientStmt: q.getClientStmt, listClientsStmt: q.listClientsStmt, diff --git a/internal/repository/oidc/oidc.sql.go b/internal/repository/oidc/oidc.sql.go index 31420be..25ae339 100644 --- a/internal/repository/oidc/oidc.sql.go +++ b/internal/repository/oidc/oidc.sql.go @@ -42,6 +42,15 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) erro return err } +const deleteAllClients = `-- name: DeleteAllClients :exec +DELETE FROM clients +` + +func (q *Queries) DeleteAllClients(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteAllClientsStmt, deleteAllClients) + return err +} + const deleteClient = `-- name: DeleteClient :exec DELETE FROM clients WHERE client_id = ? ` diff --git a/internal/repository/oidc/querier.go b/internal/repository/oidc/querier.go index 951d3bd..d4a54ea 100644 --- a/internal/repository/oidc/querier.go +++ b/internal/repository/oidc/querier.go @@ -11,6 +11,7 @@ import ( type Querier interface { // Client queries CreateClient(ctx context.Context, arg CreateClientParams) error + DeleteAllClients(ctx context.Context) error DeleteClient(ctx context.Context, clientID string) error GetClient(ctx context.Context, clientID string) (Client, error) ListClients(ctx context.Context) ([]Client, error) From 25cdca1ab48791058eec0e12b84da76f91527288 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:47:42 +0900 Subject: [PATCH 02/26] feat: add client CRUD API definition and generated code --- api/openapi.yaml | 225 +++++++++++++- internal/router/v1/gen/models.go | 75 +++++ internal/router/v1/gen/server.go | 491 +++++++++++++++++++++++++++++++ 3 files changed, 789 insertions(+), 2 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index f547591..a41b074 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -13,7 +13,228 @@ servers: - url: "http://localhost:8080" description: local -paths: {} +paths: + /api/v1/admin/clients: + get: + operationId: getClients + summary: クライアント一覧取得 + tags: + - clients + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Client" + "401": + description: Unauthorized + post: + operationId: createClient + summary: クライアント作成 + tags: + - clients + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClientCreate" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/ClientWithSecret" + "400": + description: Bad Request + "401": + description: Unauthorized + + /api/v1/admin/clients/{clientId}: + parameters: + - name: clientId + in: path + required: true + schema: + type: string + format: uuid + get: + operationId: getClient + summary: クライアント取得 + tags: + - clients + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + "401": + description: Unauthorized + "404": + description: Not Found + put: + operationId: updateClient + summary: クライアント更新 + tags: + - clients + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClientUpdate" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Client" + "400": + description: Bad Request + "401": + description: Unauthorized + "404": + description: Not Found + delete: + operationId: deleteClient + summary: クライアント削除 + tags: + - clients + responses: + "204": + description: No Content + "401": + description: Unauthorized + "404": + description: Not Found + + /api/v1/admin/clients/{clientId}/secret: + parameters: + - name: clientId + in: path + required: true + schema: + type: string + format: uuid + post: + operationId: regenerateClientSecret + summary: クライアントシークレット再生成 + tags: + - clients + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ClientSecret" + "401": + description: Unauthorized + "404": + description: Not Found components: - schemas: {} + schemas: + ClientType: + type: string + enum: + - public + - confidential + description: | + - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> + - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) + + Client: + type: object + required: + - client_id + - name + - client_type + - redirect_uris + - created_at + - updated_at + properties: + client_id: + type: string + format: uuid + name: + type: string + maxLength: 255 + client_type: + $ref: "#/components/schemas/ClientType" + redirect_uris: + type: array + items: + type: string + format: uri + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ClientCreate: + type: object + required: + - name + - client_type + - redirect_uris + properties: + name: + type: string + maxLength: 255 + client_type: + $ref: "#/components/schemas/ClientType" + redirect_uris: + type: array + items: + type: string + format: uri + minItems: 1 + + ClientUpdate: + type: object + required: + - name + - client_type + - redirect_uris + properties: + name: + type: string + maxLength: 255 + client_type: + $ref: "#/components/schemas/ClientType" + redirect_uris: + type: array + items: + type: string + format: uri + minItems: 1 + + ClientWithSecret: + allOf: + - $ref: "#/components/schemas/Client" + - type: object + required: + - client_secret + properties: + client_secret: + type: string + description: クライアントシークレット (作成時のみ返却、再取得不可) + + ClientSecret: + type: object + required: + - client_secret + properties: + client_secret: + type: string + description: 新しいクライアントシークレット diff --git a/internal/router/v1/gen/models.go b/internal/router/v1/gen/models.go index be11371..e9b0aff 100644 --- a/internal/router/v1/gen/models.go +++ b/internal/router/v1/gen/models.go @@ -2,3 +2,78 @@ // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. package gen + +import ( + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for ClientType. +const ( + Confidential ClientType = "confidential" + Public ClientType = "public" +) + +// Client defines model for Client. +type Client struct { + ClientId openapi_types.UUID `json:"client_id"` + + // ClientType - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> + // - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) + ClientType ClientType `json:"client_type"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + RedirectUris []string `json:"redirect_uris"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ClientCreate defines model for ClientCreate. +type ClientCreate struct { + // ClientType - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> + // - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) + ClientType ClientType `json:"client_type"` + Name string `json:"name"` + RedirectUris []string `json:"redirect_uris"` +} + +// ClientSecret defines model for ClientSecret. +type ClientSecret struct { + // ClientSecret 新しいクライアントシークレット + ClientSecret string `json:"client_secret"` +} + +// ClientType - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> +// - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) +type ClientType string + +// ClientUpdate defines model for ClientUpdate. +type ClientUpdate struct { + // ClientType - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> + // - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) + ClientType ClientType `json:"client_type"` + Name string `json:"name"` + RedirectUris []string `json:"redirect_uris"` +} + +// ClientWithSecret defines model for ClientWithSecret. +type ClientWithSecret struct { + ClientId openapi_types.UUID `json:"client_id"` + + // ClientSecret クライアントシークレット (作成時のみ返却、再取得不可) + ClientSecret string `json:"client_secret"` + + // ClientType - public: クライアントシークレットを安全に保持できないクライアント (SPA, モバイルアプリ等) <将来実装> + // - confidential: クライアントシークレットを安全に保持できるクライアント (サーバーサイドアプリ) + ClientType ClientType `json:"client_type"` + CreatedAt time.Time `json:"created_at"` + Name string `json:"name"` + RedirectUris []string `json:"redirect_uris"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateClientJSONRequestBody defines body for CreateClient for application/json ContentType. +type CreateClientJSONRequestBody = ClientCreate + +// UpdateClientJSONRequestBody defines body for UpdateClient for application/json ContentType. +type UpdateClientJSONRequestBody = ClientUpdate diff --git a/internal/router/v1/gen/server.go b/internal/router/v1/gen/server.go index 7712313..436a600 100644 --- a/internal/router/v1/gen/server.go +++ b/internal/router/v1/gen/server.go @@ -4,12 +4,37 @@ package gen import ( + "context" + "encoding/json" + "fmt" + "net/http" + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" + openapi_types "github.com/oapi-codegen/runtime/types" ) // ServerInterface represents all server handlers. type ServerInterface interface { + // クライアント一覧取得 + // (GET /api/v1/admin/clients) + GetClients(ctx echo.Context) error + // クライアント作成 + // (POST /api/v1/admin/clients) + CreateClient(ctx echo.Context) error + // クライアント削除 + // (DELETE /api/v1/admin/clients/{clientId}) + DeleteClient(ctx echo.Context, clientId openapi_types.UUID) error + // クライアント取得 + // (GET /api/v1/admin/clients/{clientId}) + GetClient(ctx echo.Context, clientId openapi_types.UUID) error + // クライアント更新 + // (PUT /api/v1/admin/clients/{clientId}) + UpdateClient(ctx echo.Context, clientId openapi_types.UUID) error + // クライアントシークレット再生成 + // (POST /api/v1/admin/clients/{clientId}/secret) + RegenerateClientSecret(ctx echo.Context, clientId openapi_types.UUID) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -17,6 +42,88 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetClients converts echo context to params. +func (w *ServerInterfaceWrapper) GetClients(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetClients(ctx) + return err +} + +// CreateClient converts echo context to params. +func (w *ServerInterfaceWrapper) CreateClient(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateClient(ctx) + return err +} + +// DeleteClient converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteClient(ctx echo.Context) error { + var err error + // ------------- Path parameter "clientId" ------------- + var clientId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "clientId", ctx.Param("clientId"), &clientId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter clientId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteClient(ctx, clientId) + return err +} + +// GetClient converts echo context to params. +func (w *ServerInterfaceWrapper) GetClient(ctx echo.Context) error { + var err error + // ------------- Path parameter "clientId" ------------- + var clientId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "clientId", ctx.Param("clientId"), &clientId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter clientId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetClient(ctx, clientId) + return err +} + +// UpdateClient converts echo context to params. +func (w *ServerInterfaceWrapper) UpdateClient(ctx echo.Context) error { + var err error + // ------------- Path parameter "clientId" ------------- + var clientId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "clientId", ctx.Param("clientId"), &clientId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter clientId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.UpdateClient(ctx, clientId) + return err +} + +// RegenerateClientSecret converts echo context to params. +func (w *ServerInterfaceWrapper) RegenerateClientSecret(ctx echo.Context) error { + var err error + // ------------- Path parameter "clientId" ------------- + var clientId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "clientId", ctx.Param("clientId"), &clientId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter clientId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.RegenerateClientSecret(ctx, clientId) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -41,10 +148,236 @@ func RegisterHandlers(router EchoRouter, si ServerInterface) { // can be served under a prefix. func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/api/v1/admin/clients", wrapper.GetClients) + router.POST(baseURL+"/api/v1/admin/clients", wrapper.CreateClient) + router.DELETE(baseURL+"/api/v1/admin/clients/:clientId", wrapper.DeleteClient) + router.GET(baseURL+"/api/v1/admin/clients/:clientId", wrapper.GetClient) + router.PUT(baseURL+"/api/v1/admin/clients/:clientId", wrapper.UpdateClient) + router.POST(baseURL+"/api/v1/admin/clients/:clientId/secret", wrapper.RegenerateClientSecret) + +} + +type GetClientsRequestObject struct { +} + +type GetClientsResponseObject interface { + VisitGetClientsResponse(w http.ResponseWriter) error +} + +type GetClients200JSONResponse []Client + +func (response GetClients200JSONResponse) VisitGetClientsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetClients401Response struct { +} + +func (response GetClients401Response) VisitGetClientsResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type CreateClientRequestObject struct { + Body *CreateClientJSONRequestBody +} + +type CreateClientResponseObject interface { + VisitCreateClientResponse(w http.ResponseWriter) error +} + +type CreateClient201JSONResponse ClientWithSecret + +func (response CreateClient201JSONResponse) VisitCreateClientResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type CreateClient400Response struct { +} + +func (response CreateClient400Response) VisitCreateClientResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type CreateClient401Response struct { +} + +func (response CreateClient401Response) VisitCreateClientResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteClientRequestObject struct { + ClientId openapi_types.UUID `json:"clientId"` +} + +type DeleteClientResponseObject interface { + VisitDeleteClientResponse(w http.ResponseWriter) error +} + +type DeleteClient204Response struct { +} + +func (response DeleteClient204Response) VisitDeleteClientResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DeleteClient401Response struct { +} + +func (response DeleteClient401Response) VisitDeleteClientResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type DeleteClient404Response struct { +} + +func (response DeleteClient404Response) VisitDeleteClientResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type GetClientRequestObject struct { + ClientId openapi_types.UUID `json:"clientId"` +} + +type GetClientResponseObject interface { + VisitGetClientResponse(w http.ResponseWriter) error +} + +type GetClient200JSONResponse Client + +func (response GetClient200JSONResponse) VisitGetClientResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetClient401Response struct { +} + +func (response GetClient401Response) VisitGetClientResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type GetClient404Response struct { +} + +func (response GetClient404Response) VisitGetClientResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type UpdateClientRequestObject struct { + ClientId openapi_types.UUID `json:"clientId"` + Body *UpdateClientJSONRequestBody +} + +type UpdateClientResponseObject interface { + VisitUpdateClientResponse(w http.ResponseWriter) error +} + +type UpdateClient200JSONResponse Client + +func (response UpdateClient200JSONResponse) VisitUpdateClientResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateClient400Response struct { +} + +func (response UpdateClient400Response) VisitUpdateClientResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type UpdateClient401Response struct { +} + +func (response UpdateClient401Response) VisitUpdateClientResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type UpdateClient404Response struct { +} + +func (response UpdateClient404Response) VisitUpdateClientResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type RegenerateClientSecretRequestObject struct { + ClientId openapi_types.UUID `json:"clientId"` +} + +type RegenerateClientSecretResponseObject interface { + VisitRegenerateClientSecretResponse(w http.ResponseWriter) error +} + +type RegenerateClientSecret200JSONResponse ClientSecret + +func (response RegenerateClientSecret200JSONResponse) VisitRegenerateClientSecretResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type RegenerateClientSecret401Response struct { +} + +func (response RegenerateClientSecret401Response) VisitRegenerateClientSecretResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type RegenerateClientSecret404Response struct { +} + +func (response RegenerateClientSecret404Response) VisitRegenerateClientSecretResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil } // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // クライアント一覧取得 + // (GET /api/v1/admin/clients) + GetClients(ctx context.Context, request GetClientsRequestObject) (GetClientsResponseObject, error) + // クライアント作成 + // (POST /api/v1/admin/clients) + CreateClient(ctx context.Context, request CreateClientRequestObject) (CreateClientResponseObject, error) + // クライアント削除 + // (DELETE /api/v1/admin/clients/{clientId}) + DeleteClient(ctx context.Context, request DeleteClientRequestObject) (DeleteClientResponseObject, error) + // クライアント取得 + // (GET /api/v1/admin/clients/{clientId}) + GetClient(ctx context.Context, request GetClientRequestObject) (GetClientResponseObject, error) + // クライアント更新 + // (PUT /api/v1/admin/clients/{clientId}) + UpdateClient(ctx context.Context, request UpdateClientRequestObject) (UpdateClientResponseObject, error) + // クライアントシークレット再生成 + // (POST /api/v1/admin/clients/{clientId}/secret) + RegenerateClientSecret(ctx context.Context, request RegenerateClientSecretRequestObject) (RegenerateClientSecretResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -58,3 +391,161 @@ type strictHandler struct { ssi StrictServerInterface middlewares []StrictMiddlewareFunc } + +// GetClients operation middleware +func (sh *strictHandler) GetClients(ctx echo.Context) error { + var request GetClientsRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetClients(ctx.Request().Context(), request.(GetClientsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetClients") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetClientsResponseObject); ok { + return validResponse.VisitGetClientsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// CreateClient operation middleware +func (sh *strictHandler) CreateClient(ctx echo.Context) error { + var request CreateClientRequestObject + + var body CreateClientJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.CreateClient(ctx.Request().Context(), request.(CreateClientRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateClient") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(CreateClientResponseObject); ok { + return validResponse.VisitCreateClientResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// DeleteClient operation middleware +func (sh *strictHandler) DeleteClient(ctx echo.Context, clientId openapi_types.UUID) error { + var request DeleteClientRequestObject + + request.ClientId = clientId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteClient(ctx.Request().Context(), request.(DeleteClientRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteClient") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(DeleteClientResponseObject); ok { + return validResponse.VisitDeleteClientResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetClient operation middleware +func (sh *strictHandler) GetClient(ctx echo.Context, clientId openapi_types.UUID) error { + var request GetClientRequestObject + + request.ClientId = clientId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetClient(ctx.Request().Context(), request.(GetClientRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetClient") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetClientResponseObject); ok { + return validResponse.VisitGetClientResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// UpdateClient operation middleware +func (sh *strictHandler) UpdateClient(ctx echo.Context, clientId openapi_types.UUID) error { + var request UpdateClientRequestObject + + request.ClientId = clientId + + var body UpdateClientJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpdateClient(ctx.Request().Context(), request.(UpdateClientRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateClient") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(UpdateClientResponseObject); ok { + return validResponse.VisitUpdateClientResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// RegenerateClientSecret operation middleware +func (sh *strictHandler) RegenerateClientSecret(ctx echo.Context, clientId openapi_types.UUID) error { + var request RegenerateClientSecretRequestObject + + request.ClientId = clientId + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.RegenerateClientSecret(ctx.Request().Context(), request.(RegenerateClientSecretRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "RegenerateClientSecret") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(RegenerateClientSecretResponseObject); ok { + return validResponse.VisitRegenerateClientSecretResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} From bde5d01d7453d3db6d64d2cd040c1a9158731f03 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:47:46 +0900 Subject: [PATCH 03/26] feat: implement client CRUD domain, repository, usecase, and router --- internal/domain/client.go | 28 +++ internal/repository/client.go | 129 ++++++++++++++ internal/router/v1/client.go | 130 ++++++++++++++ internal/router/v1/handler.go | 13 ++ internal/testutil/root.go | 26 +++ internal/usecase/client.go | 161 +++++++++++++++++ internal/usecase/client_test.go | 297 ++++++++++++++++++++++++++++++++ 7 files changed, 784 insertions(+) create mode 100644 internal/domain/client.go create mode 100644 internal/repository/client.go create mode 100644 internal/router/v1/client.go create mode 100644 internal/router/v1/handler.go create mode 100644 internal/testutil/root.go create mode 100644 internal/usecase/client.go create mode 100644 internal/usecase/client_test.go diff --git a/internal/domain/client.go b/internal/domain/client.go new file mode 100644 index 0000000..d8d64e4 --- /dev/null +++ b/internal/domain/client.go @@ -0,0 +1,28 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +type ClientType string + +const ( + ClientTypePublic ClientType = "public" + ClientTypeConfidential ClientType = "confidential" +) + +type Client struct { + ClientID uuid.UUID + Name string + ClientType ClientType + RedirectURIs []string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ClientWithSecret struct { + Client + ClientSecret string +} diff --git a/internal/repository/client.go b/internal/repository/client.go new file mode 100644 index 0000000..90db1fb --- /dev/null +++ b/internal/repository/client.go @@ -0,0 +1,129 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/google/uuid" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +var ErrClientNotFound = errors.New("client not found") + +type ClientRepository interface { + Create(ctx context.Context, client *domain.Client, secretHash string) error + Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) + List(ctx context.Context) ([]*domain.Client, error) + Update(ctx context.Context, client *domain.Client) error + UpdateSecret(ctx context.Context, clientID uuid.UUID, secretHash string) error + Delete(ctx context.Context, clientID uuid.UUID) error +} + +type clientRepository struct { + queries *oidc.Queries +} + +func NewClientRepository(queries *oidc.Queries) ClientRepository { + return &clientRepository{queries: queries} +} + +func (r *clientRepository) Create(ctx context.Context, client *domain.Client, secretHash string) error { + redirectURIsJSON, err := json.Marshal(client.RedirectURIs) + if err != nil { + return err + } + + return r.queries.CreateClient(ctx, oidc.CreateClientParams{ + ClientID: client.ClientID.String(), + ClientSecretHash: sql.NullString{ + String: secretHash, + Valid: secretHash != "", + }, + Name: client.Name, + ClientType: string(client.ClientType), + RedirectUris: redirectURIsJSON, + }) +} + +func (r *clientRepository) Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) { + dbClient, err := r.queries.GetClient(ctx, clientID.String()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrClientNotFound + } + return nil, err + } + + return r.toDomain(dbClient) +} + +func (r *clientRepository) List(ctx context.Context) ([]*domain.Client, error) { + dbClients, err := r.queries.ListClients(ctx) + if err != nil { + return nil, err + } + + clients := make([]*domain.Client, 0, len(dbClients)) + for _, dbClient := range dbClients { + client, err := r.toDomain(dbClient) + if err != nil { + return nil, err + } + clients = append(clients, client) + } + + return clients, nil +} + +func (r *clientRepository) Update(ctx context.Context, client *domain.Client) error { + redirectURIsJSON, err := json.Marshal(client.RedirectURIs) + if err != nil { + return err + } + + return r.queries.UpdateClient(ctx, oidc.UpdateClientParams{ + ClientID: client.ClientID.String(), + Name: client.Name, + ClientType: string(client.ClientType), + RedirectUris: redirectURIsJSON, + }) +} + +func (r *clientRepository) UpdateSecret(ctx context.Context, clientID uuid.UUID, secretHash string) error { + return r.queries.UpdateClientSecret(ctx, oidc.UpdateClientSecretParams{ + ClientID: clientID.String(), + ClientSecretHash: sql.NullString{ + String: secretHash, + Valid: secretHash != "", + }, + }) +} + +func (r *clientRepository) Delete(ctx context.Context, clientID uuid.UUID) error { + return r.queries.DeleteClient(ctx, clientID.String()) +} + +func (r *clientRepository) toDomain(dbClient oidc.Client) (*domain.Client, error) { + clientID, err := uuid.Parse(dbClient.ClientID) + if err != nil { + return nil, err + } + + var redirectURIs []string + if err := json.Unmarshal(dbClient.RedirectUris, &redirectURIs); err != nil { + return nil, err + } + + return &domain.Client{ + ClientID: clientID, + Name: dbClient.Name, + ClientType: domain.ClientType(dbClient.ClientType), + RedirectURIs: redirectURIs, + CreatedAt: dbClient.CreatedAt, + UpdatedAt: dbClient.UpdatedAt, + }, nil +} diff --git a/internal/router/v1/client.go b/internal/router/v1/client.go new file mode 100644 index 0000000..644950d --- /dev/null +++ b/internal/router/v1/client.go @@ -0,0 +1,130 @@ +package v1 + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + openapi_types "github.com/oapi-codegen/runtime/types" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/router/v1/gen" + "github.com/traPtitech/portal-oidc/internal/usecase" +) + +func (h *Handler) GetClients(ctx echo.Context) error { + clients, err := h.clientUseCase.List(ctx.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + response := make([]gen.Client, 0, len(clients)) + for _, c := range clients { + response = append(response, toClientResponse(c)) + } + + return ctx.JSON(http.StatusOK, response) +} + +func (h *Handler) CreateClient(ctx echo.Context) error { + var req gen.ClientCreate + if err := ctx.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + client, err := h.clientUseCase.Create( + ctx.Request().Context(), + req.Name, + domain.ClientType(req.ClientType), + req.RedirectUris, + ) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return ctx.JSON(http.StatusCreated, toClientWithSecretResponse(client)) +} + +func (h *Handler) GetClient(ctx echo.Context, clientId openapi_types.UUID) error { + client, err := h.clientUseCase.Get(ctx.Request().Context(), clientId) + if err != nil { + if errors.Is(err, usecase.ErrClientNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "client not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return ctx.JSON(http.StatusOK, toClientResponse(client)) +} + +func (h *Handler) UpdateClient(ctx echo.Context, clientId openapi_types.UUID) error { + var req gen.ClientUpdate + if err := ctx.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + client, err := h.clientUseCase.Update( + ctx.Request().Context(), + clientId, + req.Name, + domain.ClientType(req.ClientType), + req.RedirectUris, + ) + if err != nil { + if errors.Is(err, usecase.ErrClientNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "client not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return ctx.JSON(http.StatusOK, toClientResponse(client)) +} + +func (h *Handler) DeleteClient(ctx echo.Context, clientId openapi_types.UUID) error { + err := h.clientUseCase.Delete(ctx.Request().Context(), clientId) + if err != nil { + if errors.Is(err, usecase.ErrClientNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "client not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return ctx.NoContent(http.StatusNoContent) +} + +func (h *Handler) RegenerateClientSecret(ctx echo.Context, clientId openapi_types.UUID) error { + secret, err := h.clientUseCase.RegenerateSecret(ctx.Request().Context(), clientId) + if err != nil { + if errors.Is(err, usecase.ErrClientNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "client not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return ctx.JSON(http.StatusOK, gen.ClientSecret{ + ClientSecret: secret, + }) +} + +func toClientResponse(c *domain.Client) gen.Client { + return gen.Client{ + ClientId: c.ClientID, + Name: c.Name, + ClientType: gen.ClientType(c.ClientType), + RedirectUris: c.RedirectURIs, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func toClientWithSecretResponse(c *domain.ClientWithSecret) gen.ClientWithSecret { + return gen.ClientWithSecret{ + ClientId: c.ClientID, + Name: c.Name, + ClientType: gen.ClientType(c.ClientType), + RedirectUris: c.RedirectURIs, + ClientSecret: c.ClientSecret, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} diff --git a/internal/router/v1/handler.go b/internal/router/v1/handler.go new file mode 100644 index 0000000..6386d44 --- /dev/null +++ b/internal/router/v1/handler.go @@ -0,0 +1,13 @@ +package v1 + +import "github.com/traPtitech/portal-oidc/internal/usecase" + +type Handler struct { + clientUseCase usecase.ClientUseCase +} + +func NewHandler(clientUseCase usecase.ClientUseCase) *Handler { + return &Handler{ + clientUseCase: clientUseCase, + } +} diff --git a/internal/testutil/root.go b/internal/testutil/root.go new file mode 100644 index 0000000..e03a70d --- /dev/null +++ b/internal/testutil/root.go @@ -0,0 +1,26 @@ +package testutil + +import ( + "os" + "path/filepath" +) + +// FindProjectRoot finds the project root by looking for go.mod +func FindProjectRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} diff --git a/internal/usecase/client.go b/internal/usecase/client.go new file mode 100644 index 0000000..315ac56 --- /dev/null +++ b/internal/usecase/client.go @@ -0,0 +1,161 @@ +package usecase + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" +) + +var ErrClientNotFound = errors.New("client not found") + +type ClientUseCase interface { + Create(ctx context.Context, name string, clientType domain.ClientType, redirectURIs []string) (*domain.ClientWithSecret, error) + Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) + List(ctx context.Context) ([]*domain.Client, error) + Update(ctx context.Context, clientID uuid.UUID, name string, clientType domain.ClientType, redirectURIs []string) (*domain.Client, error) + RegenerateSecret(ctx context.Context, clientID uuid.UUID) (string, error) + Delete(ctx context.Context, clientID uuid.UUID) error +} + +type clientUseCase struct { + repo repository.ClientRepository +} + +func NewClientUseCase(repo repository.ClientRepository) ClientUseCase { + return &clientUseCase{repo: repo} +} + +func (u *clientUseCase) Create(ctx context.Context, name string, clientType domain.ClientType, redirectURIs []string) (*domain.ClientWithSecret, error) { + clientID := uuid.New() + + secret, err := generateSecret() + if err != nil { + return nil, err + } + + secretHash, err := hashSecret(secret) + if err != nil { + return nil, err + } + + now := time.Now() + client := &domain.Client{ + ClientID: clientID, + Name: name, + ClientType: clientType, + RedirectURIs: redirectURIs, + CreatedAt: now, + UpdatedAt: now, + } + + if err := u.repo.Create(ctx, client, secretHash); err != nil { + return nil, err + } + + createdClient, err := u.repo.Get(ctx, clientID) + if err != nil { + return nil, err + } + + return &domain.ClientWithSecret{ + Client: *createdClient, + ClientSecret: secret, + }, nil +} + +func (u *clientUseCase) Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) { + client, err := u.repo.Get(ctx, clientID) + if err != nil { + if errors.Is(err, repository.ErrClientNotFound) { + return nil, ErrClientNotFound + } + return nil, err + } + return client, nil +} + +func (u *clientUseCase) List(ctx context.Context) ([]*domain.Client, error) { + return u.repo.List(ctx) +} + +func (u *clientUseCase) Update(ctx context.Context, clientID uuid.UUID, name string, clientType domain.ClientType, redirectURIs []string) (*domain.Client, error) { + existing, err := u.repo.Get(ctx, clientID) + if err != nil { + if errors.Is(err, repository.ErrClientNotFound) { + return nil, ErrClientNotFound + } + return nil, err + } + + existing.Name = name + existing.ClientType = clientType + existing.RedirectURIs = redirectURIs + + if err := u.repo.Update(ctx, existing); err != nil { + return nil, err + } + + return u.repo.Get(ctx, clientID) +} + +func (u *clientUseCase) RegenerateSecret(ctx context.Context, clientID uuid.UUID) (string, error) { + _, err := u.repo.Get(ctx, clientID) + if err != nil { + if errors.Is(err, repository.ErrClientNotFound) { + return "", ErrClientNotFound + } + return "", err + } + + secret, err := generateSecret() + if err != nil { + return "", err + } + + secretHash, err := hashSecret(secret) + if err != nil { + return "", err + } + + if err := u.repo.UpdateSecret(ctx, clientID, secretHash); err != nil { + return "", err + } + + return secret, nil +} + +func (u *clientUseCase) Delete(ctx context.Context, clientID uuid.UUID) error { + _, err := u.repo.Get(ctx, clientID) + if err != nil { + if errors.Is(err, repository.ErrClientNotFound) { + return ErrClientNotFound + } + return err + } + + return u.repo.Delete(ctx, clientID) +} + +func generateSecret() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +func hashSecret(secret string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} diff --git a/internal/usecase/client_test.go b/internal/usecase/client_test.go new file mode 100644 index 0000000..973ecc9 --- /dev/null +++ b/internal/usecase/client_test.go @@ -0,0 +1,297 @@ +package usecase + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" +) + +// mockClientRepository is an in-memory implementation for testing +type mockClientRepository struct { + mu sync.RWMutex + clients map[uuid.UUID]*clientWithHash +} + +type clientWithHash struct { + client *domain.Client + secretHash string +} + +func newMockClientRepository() *mockClientRepository { + return &mockClientRepository{ + clients: make(map[uuid.UUID]*clientWithHash), + } +} + +func (r *mockClientRepository) Create(ctx context.Context, client *domain.Client, secretHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.clients[client.ClientID] = &clientWithHash{ + client: client, + secretHash: secretHash, + } + return nil +} + +func (r *mockClientRepository) Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if c, ok := r.clients[clientID]; ok { + return c.client, nil + } + return nil, repository.ErrClientNotFound +} + +func (r *mockClientRepository) List(ctx context.Context) ([]*domain.Client, error) { + r.mu.RLock() + defer r.mu.RUnlock() + clients := make([]*domain.Client, 0, len(r.clients)) + for _, c := range r.clients { + clients = append(clients, c.client) + } + return clients, nil +} + +func (r *mockClientRepository) Update(ctx context.Context, client *domain.Client) error { + r.mu.Lock() + defer r.mu.Unlock() + if c, ok := r.clients[client.ClientID]; ok { + c.client = client + return nil + } + return repository.ErrClientNotFound +} + +func (r *mockClientRepository) UpdateSecret(ctx context.Context, clientID uuid.UUID, secretHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + if c, ok := r.clients[clientID]; ok { + c.secretHash = secretHash + return nil + } + return repository.ErrClientNotFound +} + +func (r *mockClientRepository) Delete(ctx context.Context, clientID uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.clients, clientID) + return nil +} + +func (r *mockClientRepository) getSecretHash(clientID uuid.UUID) string { + r.mu.RLock() + defer r.mu.RUnlock() + if c, ok := r.clients[clientID]; ok { + return c.secretHash + } + return "" +} + +func (r *mockClientRepository) GetWithSecretHash(ctx context.Context, clientID uuid.UUID) (*domain.Client, string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if c, ok := r.clients[clientID]; ok { + return c.client, c.secretHash, nil + } + return nil, "", repository.ErrClientNotFound +} + +func TestClientUseCase_Create(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + created, err := uc.Create(ctx, "test-client", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if created.ClientID == uuid.Nil { + t.Error("ClientID should not be nil") + } + if created.ClientSecret == "" { + t.Error("ClientSecret should not be empty") + } + if created.Name != "test-client" { + t.Errorf("Name = %q, want %q", created.Name, "test-client") + } + if created.ClientType != domain.ClientTypeConfidential { + t.Errorf("ClientType = %q, want %q", created.ClientType, domain.ClientTypeConfidential) + } + if len(created.RedirectURIs) != 1 || created.RedirectURIs[0] != "http://localhost:3000/callback" { + t.Errorf("RedirectURIs = %v, want [http://localhost:3000/callback]", created.RedirectURIs) + } +} + +func TestClientUseCase_Get(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + created, _ := uc.Create(ctx, "test-client", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + + got, err := uc.Get(ctx, created.ClientID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.ClientID != created.ClientID { + t.Errorf("ClientID = %s, want %s", got.ClientID, created.ClientID) + } + if got.Name != created.Name { + t.Errorf("Name = %q, want %q", got.Name, created.Name) + } +} + +func TestClientUseCase_Get_NotFound(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + _, err := uc.Get(ctx, uuid.New()) + if err != ErrClientNotFound { + t.Errorf("err = %v, want ErrClientNotFound", err) + } +} + +func TestClientUseCase_List(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + // Empty list + list, err := uc.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 0 { + t.Errorf("len(list) = %d, want 0", len(list)) + } + + // Create clients + _, err = uc.Create(ctx, "client1", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + if err != nil { + t.Fatalf("Create client1 failed: %v", err) + } + _, err = uc.Create(ctx, "client2", domain.ClientTypePublic, []string{"http://localhost:3001/callback"}) + if err != nil { + t.Fatalf("Create client2 failed: %v", err) + } + + list, err = uc.List(ctx) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(list) != 2 { + t.Errorf("len(list) = %d, want 2", len(list)) + } +} + +func TestClientUseCase_Update(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + created, _ := uc.Create(ctx, "original", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + + updated, err := uc.Update(ctx, created.ClientID, "updated", domain.ClientTypePublic, []string{"http://localhost:4000/callback"}) + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + if updated.ClientID != created.ClientID { + t.Error("ClientID should not change") + } + if updated.Name != "updated" { + t.Errorf("Name = %q, want %q", updated.Name, "updated") + } + if updated.ClientType != domain.ClientTypePublic { + t.Errorf("ClientType = %q, want %q", updated.ClientType, domain.ClientTypePublic) + } + if len(updated.RedirectURIs) != 1 || updated.RedirectURIs[0] != "http://localhost:4000/callback" { + t.Errorf("RedirectURIs = %v, want [http://localhost:4000/callback]", updated.RedirectURIs) + } +} + +func TestClientUseCase_Update_NotFound(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + _, err := uc.Update(ctx, uuid.New(), "name", domain.ClientTypeConfidential, []string{"http://localhost:3000"}) + if err != ErrClientNotFound { + t.Errorf("err = %v, want ErrClientNotFound", err) + } +} + +func TestClientUseCase_Delete(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + created, _ := uc.Create(ctx, "test-client", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + + err := uc.Delete(ctx, created.ClientID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + _, err = uc.Get(ctx, created.ClientID) + if err != ErrClientNotFound { + t.Errorf("err = %v, want ErrClientNotFound", err) + } +} + +func TestClientUseCase_Delete_NotFound(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + err := uc.Delete(ctx, uuid.New()) + if err != ErrClientNotFound { + t.Errorf("err = %v, want ErrClientNotFound", err) + } +} + +func TestClientUseCase_RegenerateSecret(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + created, _ := uc.Create(ctx, "test-client", domain.ClientTypeConfidential, []string{"http://localhost:3000/callback"}) + originalHash := repo.getSecretHash(created.ClientID) + + newSecret, err := uc.RegenerateSecret(ctx, created.ClientID) + if err != nil { + t.Fatalf("RegenerateSecret failed: %v", err) + } + + if newSecret == "" { + t.Error("new secret should not be empty") + } + if newSecret == created.ClientSecret { + t.Error("new secret should be different from original") + } + + newHash := repo.getSecretHash(created.ClientID) + if newHash == originalHash { + t.Error("secret hash should be updated") + } +} + +func TestClientUseCase_RegenerateSecret_NotFound(t *testing.T) { + repo := newMockClientRepository() + uc := NewClientUseCase(repo) + ctx := context.Background() + + _, err := uc.RegenerateSecret(ctx, uuid.New()) + if err != ErrClientNotFound { + t.Errorf("err = %v, want ErrClientNotFound", err) + } +} From fc3701823786514862aef1992042eb4cedd37b2b Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:47:52 +0900 Subject: [PATCH 04/26] feat: integrate client usecase into server --- cmd/serve.go | 11 ++++++----- go.mod | 4 +++- go.sum | 9 +++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index b3d64a0..dd278de 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,21 +10,22 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/repository/oidc" + v1 "github.com/traPtitech/portal-oidc/internal/router/v1" "github.com/traPtitech/portal-oidc/internal/router/v1/gen" + "github.com/traPtitech/portal-oidc/internal/usecase" ) -type Handler struct { - queries *oidc.Queries -} - func newServer(cfg Config) (http.Handler, error) { queries, err := setupDatabase(cfg.Database) if err != nil { return nil, err } - handler := &Handler{queries: queries} + clientRepo := repository.NewClientRepository(queries) + clientUC := usecase.NewClientUseCase(clientRepo) + handler := v1.NewHandler(clientUC) e := echo.New() e.Use(middleware.Recover()) diff --git a/go.mod b/go.mod index 883a32b..0e4654e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/alecthomas/kong v1.13.0 github.com/go-sql-driver/mysql v1.9.3 + github.com/google/uuid v1.5.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/confmap v1.0.0 github.com/knadh/koanf/providers/env v1.1.0 @@ -12,10 +13,12 @@ require ( github.com/knadh/koanf/v2 v2.3.0 github.com/labstack/echo/v4 v4.15.0 github.com/oapi-codegen/runtime v1.1.2 + golang.org/x/crypto v0.46.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -48,7 +51,6 @@ require ( github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/go.sum b/go.sum index 078406c..41b0634 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.13.0 h1:5e/7XC3ugvhP1DQBmTS+WuHtCbcv44hsohMgcvVxSrA= github.com/alecthomas/kong v1.13.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -49,12 +53,15 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= @@ -128,7 +135,9 @@ github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= From b4dbbc47383a2b79f330d301ad88e554cdceba8f Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:55:48 +0900 Subject: [PATCH 05/26] test: add client integration tests --- internal/router/v1/client_test.go | 449 ++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 internal/router/v1/client_test.go diff --git a/internal/router/v1/client_test.go b/internal/router/v1/client_test.go new file mode 100644 index 0000000..bd7975f --- /dev/null +++ b/internal/router/v1/client_test.go @@ -0,0 +1,449 @@ +package v1 + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + _ "github.com/go-sql-driver/mysql" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "github.com/labstack/echo/v4" + + "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" + "github.com/traPtitech/portal-oidc/internal/router/v1/gen" + "github.com/traPtitech/portal-oidc/internal/testutil" + "github.com/traPtitech/portal-oidc/internal/usecase" +) + +const ( + testDBName = "oidc_test" +) + +var testDB *sql.DB + +func TestMain(m *testing.M) { + k := koanf.New(".") + ctx := context.Background() + + _ = k.Load(confmap.Provider(map[string]any{ + "mariadb.username": "root", + "mariadb.password": "password", + "mariadb.hostname": "127.0.0.1", + "mariadb.port": "3307", + }, "."), nil) + + _ = k.Load(env.Provider("MARIADB_", ".", func(s string) string { + return strings.ToLower(strings.TrimPrefix(s, "MARIADB_")) + }), nil) + + user := k.String("mariadb.username") + pass := k.String("mariadb.password") + host := k.String("mariadb.hostname") + port := k.String("mariadb.port") + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?parseTime=true", user, pass, host, port) + db, err := sql.Open("mysql", dsn) + if err != nil { + fmt.Printf("failed to connect to database: %v\n", err) + os.Exit(1) + } + + if err := db.PingContext(ctx); err != nil { + fmt.Printf("failed to ping database: %v\n", err) + os.Exit(1) + } + + _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", testDBName)) + if err != nil { + fmt.Printf("failed to create test database: %v\n", err) + os.Exit(1) + } + _ = db.Close() + + dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&multiStatements=true", user, pass, host, port, testDBName) + testDB, err = sql.Open("mysql", dsn) + if err != nil { + fmt.Printf("failed to connect to test database: %v\n", err) + os.Exit(1) + } + + root, err := testutil.FindProjectRoot() + if err != nil { + fmt.Printf("failed to find project root: %v\n", err) + os.Exit(1) + } + schemaPath := filepath.Join(root, "db", "schema.sql") + schemaSQL, err := os.ReadFile(schemaPath) //nolint:gosec + if err != nil { + fmt.Printf("failed to read schema file: %v\n", err) + os.Exit(1) + } + + _, err = testDB.ExecContext(ctx, string(schemaSQL)) + if err != nil { + fmt.Printf("failed to create schema: %v\n", err) + os.Exit(1) + } + + code := m.Run() + + _ = testDB.Close() + + os.Exit(code) +} + +func setupTestHandler(t *testing.T) (*Handler, func()) { + t.Helper() + + ctx := context.Background() + + queries, err := oidc.Prepare(ctx, testDB) + if err != nil { + t.Fatalf("failed to prepare queries: %v", err) + } + + if err := queries.DeleteAllClients(ctx); err != nil { + t.Fatalf("failed to clean up clients table: %v", err) + } + + clientRepo := repository.NewClientRepository(queries) + clientUseCase := usecase.NewClientUseCase(clientRepo) + + handler := NewHandler(clientUseCase) + + cleanup := func() { + _ = queries.DeleteAllClients(ctx) + _ = queries.Close() + } + + return handler, cleanup +} + +func TestIntegration_CreateClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"integration-test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.Name != "integration-test-client" { + t.Errorf("Name = %q, want %q", resp.Name, "integration-test-client") + } + if resp.ClientType != gen.Confidential { + t.Errorf("ClientType = %q, want %q", resp.ClientType, gen.Confidential) + } + if resp.ClientSecret == "" { + t.Error("ClientSecret should not be empty") + } + if len(resp.RedirectUris) != 1 { + t.Errorf("len(RedirectUris) = %d, want 1", len(resp.RedirectUris)) + } +} + +func TestIntegration_GetClients(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var clients []gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(clients) != 1 { + t.Errorf("len(clients) = %d, want 1", len(clients)) + } +} + +func TestIntegration_GetClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var client gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &client); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if client.ClientId != created.ClientId { + t.Errorf("ClientId = %s, want %s", client.ClientId, created.ClientId) + } + if client.Name != "test-client" { + t.Errorf("Name = %q, want %q", client.Name, "test-client") + } +} + +func TestIntegration_GetClient_NotFound(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/00000000-0000-0000-0000-000000000000", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestIntegration_UpdateClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"original","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + updateBody := `{"name":"updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var updated gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if updated.Name != "updated" { + t.Errorf("Name = %q, want %q", updated.Name, "updated") + } + if updated.ClientType != gen.Public { + t.Errorf("ClientType = %q, want %q", updated.ClientType, gen.Public) + } + if updated.RedirectUris[0] != "http://localhost:4000/callback" { + t.Errorf("RedirectUris[0] = %q, want %q", updated.RedirectUris[0], "http://localhost:4000/callback") + } +} + +func TestIntegration_DeleteClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"to-delete","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestIntegration_RegenerateClientSecret(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var secret gen.ClientSecret + if err := json.Unmarshal(rec.Body.Bytes(), &secret); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if secret.ClientSecret == "" { + t.Error("ClientSecret should not be empty") + } + if secret.ClientSecret == created.ClientSecret { + t.Error("new secret should be different from original") + } +} + +func TestIntegration_FullWorkflow(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // 1. Create client + createBody := `{"name":"workflow-test","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(createBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("Create: status = %d, want %d", rec.Code, http.StatusCreated) + } + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // 2. Verify in list + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var clients []gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(clients) != 1 { + t.Errorf("List: len = %d, want 1", len(clients)) + } + + // 3. Update client + updateBody := `{"name":"workflow-updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Update: status = %d, want %d", rec.Code, http.StatusOK) + } + + // 4. Regenerate secret + req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("RegenerateSecret: status = %d, want %d", rec.Code, http.StatusOK) + } + + // 5. Delete client + req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("Delete: status = %d, want %d", rec.Code, http.StatusNoContent) + } + + // 6. Verify list is empty + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(clients) != 0 { + t.Errorf("Final List: len = %d, want 0", len(clients)) + } +} From 5c83ed18d7989e67f30ba20cae3ae41d7d2e247f Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:53:33 +0900 Subject: [PATCH 06/26] feat: add authorization_codes and tokens tables --- db/query/oidc.sql | 75 +++++++- db/schema.sql | 35 ++++ internal/repository/oidc/db.go | 196 ++++++++++++++++++-- internal/repository/oidc/models.go | 24 +++ internal/repository/oidc/oidc.sql.go | 267 +++++++++++++++++++++++++++ internal/repository/oidc/querier.go | 19 ++ 6 files changed, 597 insertions(+), 19 deletions(-) diff --git a/db/query/oidc.sql b/db/query/oidc.sql index 250a0fe..cd8d621 100644 --- a/db/query/oidc.sql +++ b/db/query/oidc.sql @@ -31,4 +31,77 @@ WHERE client_id = ?; DELETE FROM clients WHERE client_id = ?; -- name: DeleteAllClients :exec -DELETE FROM clients; \ No newline at end of file +DELETE FROM clients; + +-- Authorization Code queries + +-- name: CreateAuthorizationCode :exec +INSERT INTO authorization_codes ( + code, + client_id, + user_id, + redirect_uri, + scopes, + code_challenge, + code_challenge_method, + nonce, + expires_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + +-- name: GetAuthorizationCode :one +SELECT * FROM authorization_codes WHERE code = ?; + +-- name: DeleteAuthorizationCode :exec +DELETE FROM authorization_codes WHERE code = ?; + +-- name: UpdateAuthorizationCodePKCE :exec +UPDATE authorization_codes SET + code_challenge = ?, + code_challenge_method = ? +WHERE code = ?; + +-- name: DeleteExpiredAuthorizationCodes :exec +DELETE FROM authorization_codes WHERE expires_at < NOW(); + +-- name: DeleteAllAuthorizationCodes :exec +DELETE FROM authorization_codes; + +-- Token queries + +-- name: CreateToken :exec +INSERT INTO tokens ( + id, + client_id, + user_id, + access_token, + refresh_token, + scopes, + expires_at +) VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: GetTokenByAccessToken :one +SELECT * FROM tokens WHERE access_token = ?; + +-- name: GetTokenByRefreshToken :one +SELECT * FROM tokens WHERE refresh_token = ?; + +-- name: GetTokenByID :one +SELECT * FROM tokens WHERE id = ?; + +-- name: DeleteToken :exec +DELETE FROM tokens WHERE id = ?; + +-- name: DeleteTokenByAccessToken :exec +DELETE FROM tokens WHERE access_token = ?; + +-- name: DeleteTokenByRefreshToken :exec +DELETE FROM tokens WHERE refresh_token = ?; + +-- name: DeleteExpiredTokens :exec +DELETE FROM tokens WHERE expires_at < NOW(); + +-- name: DeleteTokensByUserAndClient :exec +DELETE FROM tokens WHERE user_id = ? AND client_id = ?; + +-- name: DeleteAllTokens :exec +DELETE FROM tokens; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 0a40901..b77b445 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -10,3 +10,38 @@ CREATE TABLE IF NOT EXISTS `clients` ( `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`client_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `authorization_codes` ( + `code` varchar(64) NOT NULL, + `client_id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `redirect_uri` text NOT NULL, + `scopes` text NOT NULL, + `code_challenge` varchar(128) NULL, + `code_challenge_method` varchar(10) NULL, + `nonce` varchar(255) NULL, + `expires_at` datetime(6) NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`code`), + INDEX `idx_authorization_codes_client_id` (`client_id`), + INDEX `idx_authorization_codes_expires_at` (`expires_at`), + CONSTRAINT `fk_authorization_codes_client` FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `tokens` ( + `id` char(36) NOT NULL, + `client_id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `access_token` varchar(64) NOT NULL, + `refresh_token` varchar(64) NULL, + `scopes` text NOT NULL, + `expires_at` datetime(6) NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE INDEX `idx_tokens_access_token` (`access_token`), + UNIQUE INDEX `idx_tokens_refresh_token` (`refresh_token`), + INDEX `idx_tokens_client_id` (`client_id`), + INDEX `idx_tokens_user_id` (`user_id`), + INDEX `idx_tokens_expires_at` (`expires_at`), + CONSTRAINT `fk_tokens_client` FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/internal/repository/oidc/db.go b/internal/repository/oidc/db.go index 70cdeb7..7237be1 100644 --- a/internal/repository/oidc/db.go +++ b/internal/repository/oidc/db.go @@ -24,21 +24,69 @@ func New(db DBTX) *Queries { func Prepare(ctx context.Context, db DBTX) (*Queries, error) { q := Queries{db: db} var err error + if q.createAuthorizationCodeStmt, err = db.PrepareContext(ctx, createAuthorizationCode); err != nil { + return nil, fmt.Errorf("error preparing query CreateAuthorizationCode: %w", err) + } if q.createClientStmt, err = db.PrepareContext(ctx, createClient); err != nil { return nil, fmt.Errorf("error preparing query CreateClient: %w", err) } + if q.createTokenStmt, err = db.PrepareContext(ctx, createToken); err != nil { + return nil, fmt.Errorf("error preparing query CreateToken: %w", err) + } + if q.deleteAllAuthorizationCodesStmt, err = db.PrepareContext(ctx, deleteAllAuthorizationCodes); err != nil { + return nil, fmt.Errorf("error preparing query DeleteAllAuthorizationCodes: %w", err) + } if q.deleteAllClientsStmt, err = db.PrepareContext(ctx, deleteAllClients); err != nil { return nil, fmt.Errorf("error preparing query DeleteAllClients: %w", err) } + if q.deleteAllTokensStmt, err = db.PrepareContext(ctx, deleteAllTokens); err != nil { + return nil, fmt.Errorf("error preparing query DeleteAllTokens: %w", err) + } + if q.deleteAuthorizationCodeStmt, err = db.PrepareContext(ctx, deleteAuthorizationCode); err != nil { + return nil, fmt.Errorf("error preparing query DeleteAuthorizationCode: %w", err) + } if q.deleteClientStmt, err = db.PrepareContext(ctx, deleteClient); err != nil { return nil, fmt.Errorf("error preparing query DeleteClient: %w", err) } + if q.deleteExpiredAuthorizationCodesStmt, err = db.PrepareContext(ctx, deleteExpiredAuthorizationCodes); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredAuthorizationCodes: %w", err) + } + if q.deleteExpiredTokensStmt, err = db.PrepareContext(ctx, deleteExpiredTokens); err != nil { + return nil, fmt.Errorf("error preparing query DeleteExpiredTokens: %w", err) + } + if q.deleteTokenStmt, err = db.PrepareContext(ctx, deleteToken); err != nil { + return nil, fmt.Errorf("error preparing query DeleteToken: %w", err) + } + if q.deleteTokenByAccessTokenStmt, err = db.PrepareContext(ctx, deleteTokenByAccessToken); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTokenByAccessToken: %w", err) + } + if q.deleteTokenByRefreshTokenStmt, err = db.PrepareContext(ctx, deleteTokenByRefreshToken); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTokenByRefreshToken: %w", err) + } + if q.deleteTokensByUserAndClientStmt, err = db.PrepareContext(ctx, deleteTokensByUserAndClient); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTokensByUserAndClient: %w", err) + } + if q.getAuthorizationCodeStmt, err = db.PrepareContext(ctx, getAuthorizationCode); err != nil { + return nil, fmt.Errorf("error preparing query GetAuthorizationCode: %w", err) + } if q.getClientStmt, err = db.PrepareContext(ctx, getClient); err != nil { return nil, fmt.Errorf("error preparing query GetClient: %w", err) } + if q.getTokenByAccessTokenStmt, err = db.PrepareContext(ctx, getTokenByAccessToken); err != nil { + return nil, fmt.Errorf("error preparing query GetTokenByAccessToken: %w", err) + } + if q.getTokenByIDStmt, err = db.PrepareContext(ctx, getTokenByID); err != nil { + return nil, fmt.Errorf("error preparing query GetTokenByID: %w", err) + } + if q.getTokenByRefreshTokenStmt, err = db.PrepareContext(ctx, getTokenByRefreshToken); err != nil { + return nil, fmt.Errorf("error preparing query GetTokenByRefreshToken: %w", err) + } if q.listClientsStmt, err = db.PrepareContext(ctx, listClients); err != nil { return nil, fmt.Errorf("error preparing query ListClients: %w", err) } + if q.updateAuthorizationCodePKCEStmt, err = db.PrepareContext(ctx, updateAuthorizationCodePKCE); err != nil { + return nil, fmt.Errorf("error preparing query UpdateAuthorizationCodePKCE: %w", err) + } if q.updateClientStmt, err = db.PrepareContext(ctx, updateClient); err != nil { return nil, fmt.Errorf("error preparing query UpdateClient: %w", err) } @@ -50,31 +98,111 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { func (q *Queries) Close() error { var err error + if q.createAuthorizationCodeStmt != nil { + if cerr := q.createAuthorizationCodeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createAuthorizationCodeStmt: %w", cerr) + } + } if q.createClientStmt != nil { if cerr := q.createClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createClientStmt: %w", cerr) } } + if q.createTokenStmt != nil { + if cerr := q.createTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createTokenStmt: %w", cerr) + } + } + if q.deleteAllAuthorizationCodesStmt != nil { + if cerr := q.deleteAllAuthorizationCodesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteAllAuthorizationCodesStmt: %w", cerr) + } + } if q.deleteAllClientsStmt != nil { if cerr := q.deleteAllClientsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteAllClientsStmt: %w", cerr) } } + if q.deleteAllTokensStmt != nil { + if cerr := q.deleteAllTokensStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteAllTokensStmt: %w", cerr) + } + } + if q.deleteAuthorizationCodeStmt != nil { + if cerr := q.deleteAuthorizationCodeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteAuthorizationCodeStmt: %w", cerr) + } + } if q.deleteClientStmt != nil { if cerr := q.deleteClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteClientStmt: %w", cerr) } } + if q.deleteExpiredAuthorizationCodesStmt != nil { + if cerr := q.deleteExpiredAuthorizationCodesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredAuthorizationCodesStmt: %w", cerr) + } + } + if q.deleteExpiredTokensStmt != nil { + if cerr := q.deleteExpiredTokensStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteExpiredTokensStmt: %w", cerr) + } + } + if q.deleteTokenStmt != nil { + if cerr := q.deleteTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTokenStmt: %w", cerr) + } + } + if q.deleteTokenByAccessTokenStmt != nil { + if cerr := q.deleteTokenByAccessTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTokenByAccessTokenStmt: %w", cerr) + } + } + if q.deleteTokenByRefreshTokenStmt != nil { + if cerr := q.deleteTokenByRefreshTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTokenByRefreshTokenStmt: %w", cerr) + } + } + if q.deleteTokensByUserAndClientStmt != nil { + if cerr := q.deleteTokensByUserAndClientStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTokensByUserAndClientStmt: %w", cerr) + } + } + if q.getAuthorizationCodeStmt != nil { + if cerr := q.getAuthorizationCodeStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getAuthorizationCodeStmt: %w", cerr) + } + } if q.getClientStmt != nil { if cerr := q.getClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getClientStmt: %w", cerr) } } + if q.getTokenByAccessTokenStmt != nil { + if cerr := q.getTokenByAccessTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTokenByAccessTokenStmt: %w", cerr) + } + } + if q.getTokenByIDStmt != nil { + if cerr := q.getTokenByIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTokenByIDStmt: %w", cerr) + } + } + if q.getTokenByRefreshTokenStmt != nil { + if cerr := q.getTokenByRefreshTokenStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getTokenByRefreshTokenStmt: %w", cerr) + } + } if q.listClientsStmt != nil { if cerr := q.listClientsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listClientsStmt: %w", cerr) } } + if q.updateAuthorizationCodePKCEStmt != nil { + if cerr := q.updateAuthorizationCodePKCEStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateAuthorizationCodePKCEStmt: %w", cerr) + } + } if q.updateClientStmt != nil { if cerr := q.updateClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateClientStmt: %w", cerr) @@ -122,27 +250,59 @@ func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, ar } type Queries struct { - db DBTX - tx *sql.Tx - createClientStmt *sql.Stmt - deleteAllClientsStmt *sql.Stmt - deleteClientStmt *sql.Stmt - getClientStmt *sql.Stmt - listClientsStmt *sql.Stmt - updateClientStmt *sql.Stmt - updateClientSecretStmt *sql.Stmt + db DBTX + tx *sql.Tx + createAuthorizationCodeStmt *sql.Stmt + createClientStmt *sql.Stmt + createTokenStmt *sql.Stmt + deleteAllAuthorizationCodesStmt *sql.Stmt + deleteAllClientsStmt *sql.Stmt + deleteAllTokensStmt *sql.Stmt + deleteAuthorizationCodeStmt *sql.Stmt + deleteClientStmt *sql.Stmt + deleteExpiredAuthorizationCodesStmt *sql.Stmt + deleteExpiredTokensStmt *sql.Stmt + deleteTokenStmt *sql.Stmt + deleteTokenByAccessTokenStmt *sql.Stmt + deleteTokenByRefreshTokenStmt *sql.Stmt + deleteTokensByUserAndClientStmt *sql.Stmt + getAuthorizationCodeStmt *sql.Stmt + getClientStmt *sql.Stmt + getTokenByAccessTokenStmt *sql.Stmt + getTokenByIDStmt *sql.Stmt + getTokenByRefreshTokenStmt *sql.Stmt + listClientsStmt *sql.Stmt + updateAuthorizationCodePKCEStmt *sql.Stmt + updateClientStmt *sql.Stmt + updateClientSecretStmt *sql.Stmt } func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{ - db: tx, - tx: tx, - createClientStmt: q.createClientStmt, - deleteAllClientsStmt: q.deleteAllClientsStmt, - deleteClientStmt: q.deleteClientStmt, - getClientStmt: q.getClientStmt, - listClientsStmt: q.listClientsStmt, - updateClientStmt: q.updateClientStmt, - updateClientSecretStmt: q.updateClientSecretStmt, + db: tx, + tx: tx, + createAuthorizationCodeStmt: q.createAuthorizationCodeStmt, + createClientStmt: q.createClientStmt, + createTokenStmt: q.createTokenStmt, + deleteAllAuthorizationCodesStmt: q.deleteAllAuthorizationCodesStmt, + deleteAllClientsStmt: q.deleteAllClientsStmt, + deleteAllTokensStmt: q.deleteAllTokensStmt, + deleteAuthorizationCodeStmt: q.deleteAuthorizationCodeStmt, + deleteClientStmt: q.deleteClientStmt, + deleteExpiredAuthorizationCodesStmt: q.deleteExpiredAuthorizationCodesStmt, + deleteExpiredTokensStmt: q.deleteExpiredTokensStmt, + deleteTokenStmt: q.deleteTokenStmt, + deleteTokenByAccessTokenStmt: q.deleteTokenByAccessTokenStmt, + deleteTokenByRefreshTokenStmt: q.deleteTokenByRefreshTokenStmt, + deleteTokensByUserAndClientStmt: q.deleteTokensByUserAndClientStmt, + getAuthorizationCodeStmt: q.getAuthorizationCodeStmt, + getClientStmt: q.getClientStmt, + getTokenByAccessTokenStmt: q.getTokenByAccessTokenStmt, + getTokenByIDStmt: q.getTokenByIDStmt, + getTokenByRefreshTokenStmt: q.getTokenByRefreshTokenStmt, + listClientsStmt: q.listClientsStmt, + updateAuthorizationCodePKCEStmt: q.updateAuthorizationCodePKCEStmt, + updateClientStmt: q.updateClientStmt, + updateClientSecretStmt: q.updateClientSecretStmt, } } diff --git a/internal/repository/oidc/models.go b/internal/repository/oidc/models.go index 3a0dd8f..fa82d76 100644 --- a/internal/repository/oidc/models.go +++ b/internal/repository/oidc/models.go @@ -10,6 +10,19 @@ import ( "time" ) +type AuthorizationCode struct { + Code string `json:"code"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + RedirectUri string `json:"redirect_uri"` + Scopes string `json:"scopes"` + CodeChallenge sql.NullString `json:"code_challenge"` + CodeChallengeMethod sql.NullString `json:"code_challenge_method"` + Nonce sql.NullString `json:"nonce"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + type Client struct { ClientID string `json:"client_id"` ClientSecretHash sql.NullString `json:"client_secret_hash"` @@ -19,3 +32,14 @@ type Client struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } + +type Token struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + AccessToken string `json:"access_token"` + RefreshToken sql.NullString `json:"refresh_token"` + Scopes string `json:"scopes"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/repository/oidc/oidc.sql.go b/internal/repository/oidc/oidc.sql.go index 25ae339..7c8c921 100644 --- a/internal/repository/oidc/oidc.sql.go +++ b/internal/repository/oidc/oidc.sql.go @@ -9,8 +9,52 @@ import ( "context" "database/sql" "encoding/json" + "time" ) +const createAuthorizationCode = `-- name: CreateAuthorizationCode :exec + +INSERT INTO authorization_codes ( + code, + client_id, + user_id, + redirect_uri, + scopes, + code_challenge, + code_challenge_method, + nonce, + expires_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +` + +type CreateAuthorizationCodeParams struct { + Code string `json:"code"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + RedirectUri string `json:"redirect_uri"` + Scopes string `json:"scopes"` + CodeChallenge sql.NullString `json:"code_challenge"` + CodeChallengeMethod sql.NullString `json:"code_challenge_method"` + Nonce sql.NullString `json:"nonce"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Authorization Code queries +func (q *Queries) CreateAuthorizationCode(ctx context.Context, arg CreateAuthorizationCodeParams) error { + _, err := q.exec(ctx, q.createAuthorizationCodeStmt, createAuthorizationCode, + arg.Code, + arg.ClientID, + arg.UserID, + arg.RedirectUri, + arg.Scopes, + arg.CodeChallenge, + arg.CodeChallengeMethod, + arg.Nonce, + arg.ExpiresAt, + ) + return err +} + const createClient = `-- name: CreateClient :exec INSERT INTO clients ( @@ -42,6 +86,52 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) erro return err } +const createToken = `-- name: CreateToken :exec + +INSERT INTO tokens ( + id, + client_id, + user_id, + access_token, + refresh_token, + scopes, + expires_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateTokenParams struct { + ID string `json:"id"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + AccessToken string `json:"access_token"` + RefreshToken sql.NullString `json:"refresh_token"` + Scopes string `json:"scopes"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Token queries +func (q *Queries) CreateToken(ctx context.Context, arg CreateTokenParams) error { + _, err := q.exec(ctx, q.createTokenStmt, createToken, + arg.ID, + arg.ClientID, + arg.UserID, + arg.AccessToken, + arg.RefreshToken, + arg.Scopes, + arg.ExpiresAt, + ) + return err +} + +const deleteAllAuthorizationCodes = `-- name: DeleteAllAuthorizationCodes :exec +DELETE FROM authorization_codes +` + +func (q *Queries) DeleteAllAuthorizationCodes(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteAllAuthorizationCodesStmt, deleteAllAuthorizationCodes) + return err +} + const deleteAllClients = `-- name: DeleteAllClients :exec DELETE FROM clients ` @@ -51,6 +141,24 @@ func (q *Queries) DeleteAllClients(ctx context.Context) error { return err } +const deleteAllTokens = `-- name: DeleteAllTokens :exec +DELETE FROM tokens +` + +func (q *Queries) DeleteAllTokens(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteAllTokensStmt, deleteAllTokens) + return err +} + +const deleteAuthorizationCode = `-- name: DeleteAuthorizationCode :exec +DELETE FROM authorization_codes WHERE code = ? +` + +func (q *Queries) DeleteAuthorizationCode(ctx context.Context, code string) error { + _, err := q.exec(ctx, q.deleteAuthorizationCodeStmt, deleteAuthorizationCode, code) + return err +} + const deleteClient = `-- name: DeleteClient :exec DELETE FROM clients WHERE client_id = ? ` @@ -60,6 +168,87 @@ func (q *Queries) DeleteClient(ctx context.Context, clientID string) error { return err } +const deleteExpiredAuthorizationCodes = `-- name: DeleteExpiredAuthorizationCodes :exec +DELETE FROM authorization_codes WHERE expires_at < NOW() +` + +func (q *Queries) DeleteExpiredAuthorizationCodes(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteExpiredAuthorizationCodesStmt, deleteExpiredAuthorizationCodes) + return err +} + +const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec +DELETE FROM tokens WHERE expires_at < NOW() +` + +func (q *Queries) DeleteExpiredTokens(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteExpiredTokensStmt, deleteExpiredTokens) + return err +} + +const deleteToken = `-- name: DeleteToken :exec +DELETE FROM tokens WHERE id = ? +` + +func (q *Queries) DeleteToken(ctx context.Context, id string) error { + _, err := q.exec(ctx, q.deleteTokenStmt, deleteToken, id) + return err +} + +const deleteTokenByAccessToken = `-- name: DeleteTokenByAccessToken :exec +DELETE FROM tokens WHERE access_token = ? +` + +func (q *Queries) DeleteTokenByAccessToken(ctx context.Context, accessToken string) error { + _, err := q.exec(ctx, q.deleteTokenByAccessTokenStmt, deleteTokenByAccessToken, accessToken) + return err +} + +const deleteTokenByRefreshToken = `-- name: DeleteTokenByRefreshToken :exec +DELETE FROM tokens WHERE refresh_token = ? +` + +func (q *Queries) DeleteTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) error { + _, err := q.exec(ctx, q.deleteTokenByRefreshTokenStmt, deleteTokenByRefreshToken, refreshToken) + return err +} + +const deleteTokensByUserAndClient = `-- name: DeleteTokensByUserAndClient :exec +DELETE FROM tokens WHERE user_id = ? AND client_id = ? +` + +type DeleteTokensByUserAndClientParams struct { + UserID string `json:"user_id"` + ClientID string `json:"client_id"` +} + +func (q *Queries) DeleteTokensByUserAndClient(ctx context.Context, arg DeleteTokensByUserAndClientParams) error { + _, err := q.exec(ctx, q.deleteTokensByUserAndClientStmt, deleteTokensByUserAndClient, arg.UserID, arg.ClientID) + return err +} + +const getAuthorizationCode = `-- name: GetAuthorizationCode :one +SELECT code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, nonce, expires_at, created_at FROM authorization_codes WHERE code = ? +` + +func (q *Queries) GetAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) { + row := q.queryRow(ctx, q.getAuthorizationCodeStmt, getAuthorizationCode, code) + var i AuthorizationCode + err := row.Scan( + &i.Code, + &i.ClientID, + &i.UserID, + &i.RedirectUri, + &i.Scopes, + &i.CodeChallenge, + &i.CodeChallengeMethod, + &i.Nonce, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + const getClient = `-- name: GetClient :one SELECT client_id, client_secret_hash, name, client_type, redirect_uris, created_at, updated_at FROM clients WHERE client_id = ? ` @@ -79,6 +268,66 @@ func (q *Queries) GetClient(ctx context.Context, clientID string) (Client, error return i, err } +const getTokenByAccessToken = `-- name: GetTokenByAccessToken :one +SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE access_token = ? +` + +func (q *Queries) GetTokenByAccessToken(ctx context.Context, accessToken string) (Token, error) { + row := q.queryRow(ctx, q.getTokenByAccessTokenStmt, getTokenByAccessToken, accessToken) + var i Token + err := row.Scan( + &i.ID, + &i.ClientID, + &i.UserID, + &i.AccessToken, + &i.RefreshToken, + &i.Scopes, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getTokenByID = `-- name: GetTokenByID :one +SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE id = ? +` + +func (q *Queries) GetTokenByID(ctx context.Context, id string) (Token, error) { + row := q.queryRow(ctx, q.getTokenByIDStmt, getTokenByID, id) + var i Token + err := row.Scan( + &i.ID, + &i.ClientID, + &i.UserID, + &i.AccessToken, + &i.RefreshToken, + &i.Scopes, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const getTokenByRefreshToken = `-- name: GetTokenByRefreshToken :one +SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE refresh_token = ? +` + +func (q *Queries) GetTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) (Token, error) { + row := q.queryRow(ctx, q.getTokenByRefreshTokenStmt, getTokenByRefreshToken, refreshToken) + var i Token + err := row.Scan( + &i.ID, + &i.ClientID, + &i.UserID, + &i.AccessToken, + &i.RefreshToken, + &i.Scopes, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + const listClients = `-- name: ListClients :many SELECT client_id, client_secret_hash, name, client_type, redirect_uris, created_at, updated_at FROM clients ` @@ -114,6 +363,24 @@ func (q *Queries) ListClients(ctx context.Context) ([]Client, error) { return items, nil } +const updateAuthorizationCodePKCE = `-- name: UpdateAuthorizationCodePKCE :exec +UPDATE authorization_codes SET + code_challenge = ?, + code_challenge_method = ? +WHERE code = ? +` + +type UpdateAuthorizationCodePKCEParams struct { + CodeChallenge sql.NullString `json:"code_challenge"` + CodeChallengeMethod sql.NullString `json:"code_challenge_method"` + Code string `json:"code"` +} + +func (q *Queries) UpdateAuthorizationCodePKCE(ctx context.Context, arg UpdateAuthorizationCodePKCEParams) error { + _, err := q.exec(ctx, q.updateAuthorizationCodePKCEStmt, updateAuthorizationCodePKCE, arg.CodeChallenge, arg.CodeChallengeMethod, arg.Code) + return err +} + const updateClient = `-- name: UpdateClient :exec UPDATE clients SET name = ?, diff --git a/internal/repository/oidc/querier.go b/internal/repository/oidc/querier.go index d4a54ea..33b1031 100644 --- a/internal/repository/oidc/querier.go +++ b/internal/repository/oidc/querier.go @@ -6,15 +6,34 @@ package oidc import ( "context" + "database/sql" ) type Querier interface { + // Authorization Code queries + CreateAuthorizationCode(ctx context.Context, arg CreateAuthorizationCodeParams) error // Client queries CreateClient(ctx context.Context, arg CreateClientParams) error + // Token queries + CreateToken(ctx context.Context, arg CreateTokenParams) error + DeleteAllAuthorizationCodes(ctx context.Context) error DeleteAllClients(ctx context.Context) error + DeleteAllTokens(ctx context.Context) error + DeleteAuthorizationCode(ctx context.Context, code string) error DeleteClient(ctx context.Context, clientID string) error + DeleteExpiredAuthorizationCodes(ctx context.Context) error + DeleteExpiredTokens(ctx context.Context) error + DeleteToken(ctx context.Context, id string) error + DeleteTokenByAccessToken(ctx context.Context, accessToken string) error + DeleteTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) error + DeleteTokensByUserAndClient(ctx context.Context, arg DeleteTokensByUserAndClientParams) error + GetAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) GetClient(ctx context.Context, clientID string) (Client, error) + GetTokenByAccessToken(ctx context.Context, accessToken string) (Token, error) + GetTokenByID(ctx context.Context, id string) (Token, error) + GetTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) (Token, error) ListClients(ctx context.Context) ([]Client, error) + UpdateAuthorizationCodePKCE(ctx context.Context, arg UpdateAuthorizationCodePKCEParams) error UpdateClient(ctx context.Context, arg UpdateClientParams) error UpdateClientSecret(ctx context.Context, arg UpdateClientSecretParams) error } From 4d7b89e8a096550bc8485b2a2b59204cc09526e6 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:53:38 +0900 Subject: [PATCH 07/26] feat: add OAuth2/OIDC API definition and generated code --- api/openapi.yaml | 363 +++++++++++++++++++++++++ internal/router/v1/gen/models.go | 188 +++++++++++++ internal/router/v1/gen/server.go | 448 +++++++++++++++++++++++++++++++ 3 files changed, 999 insertions(+) diff --git a/api/openapi.yaml b/api/openapi.yaml index a41b074..193bdc6 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -140,6 +140,173 @@ paths: "404": description: Not Found + /.well-known/openid-configuration: + get: + operationId: getOpenIDConfiguration + summary: OpenID Provider Configuration + description: OpenID Connect Discovery 1.0 のメタデータを返却 + tags: + - discovery + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OpenIDConfiguration" + + /.well-known/jwks.json: + get: + operationId: getJWKS + summary: JSON Web Key Set + description: トークン検証用の公開鍵セットを返却 + tags: + - discovery + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/JWKS" + + /oauth2/userinfo: + get: + operationId: getUserInfo + summary: UserInfo エンドポイント (GET) + description: アクセストークンに紐づくユーザー情報を返却 + tags: + - oauth2/oidc + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfo" + "401": + description: Unauthorized + post: + operationId: postUserInfo + summary: UserInfo エンドポイント (POST) + description: アクセストークンに紐づくユーザー情報を返却 (OIDC Core 1.0 準拠) + tags: + - oauth2/oidc + security: + - bearerAuth: [] + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UserInfo" + "401": + description: Unauthorized + + /oauth2/authorize: + get: + operationId: authorize + summary: 認可エンドポイント + description: OAuth2/OIDC認可リクエストを処理し、ユーザー認証後にリダイレクト + tags: + - oauth2/oidc + parameters: + - name: response_type + in: query + required: true + schema: + type: string + enum: [code] + description: レスポンスタイプ (現在は code のみサポート) + - name: client_id + in: query + required: true + schema: + type: string + format: uuid + - name: redirect_uri + in: query + required: true + schema: + type: string + format: uri + - name: scope + in: query + required: false + schema: + type: string + description: スペース区切りのスコープ (openid, profile, email 等) + - name: state + in: query + required: false + schema: + type: string + description: CSRF対策用のランダム文字列 + - name: nonce + in: query + required: false + schema: + type: string + description: リプレイ攻撃対策用 (OIDC) + - name: code_challenge + in: query + required: false + schema: + type: string + description: PKCE code_challenge + - name: code_challenge_method + in: query + required: false + schema: + type: string + enum: [S256, plain] + description: PKCE code_challenge_method + responses: + "302": + description: 認可成功時はredirect_uriへリダイレクト + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + + /oauth2/token: + post: + operationId: token + summary: トークンエンドポイント + description: 認可コードをトークンに交換、またはトークンをリフレッシュ + tags: + - oauth2/oidc + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/TokenRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TokenResponse" + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/OAuthError" + components: schemas: ClientType: @@ -238,3 +405,199 @@ components: client_secret: type: string description: 新しいクライアントシークレット + + TokenRequest: + type: object + required: + - grant_type + properties: + grant_type: + type: string + enum: + - authorization_code + - refresh_token + code: + type: string + description: 認可コード (grant_type=authorization_code 時に必須) + redirect_uri: + type: string + format: uri + description: リダイレクトURI (grant_type=authorization_code 時に必須) + client_id: + type: string + format: uuid + client_secret: + type: string + description: クライアントシークレット (confidential client の場合) + refresh_token: + type: string + description: リフレッシュトークン (grant_type=refresh_token 時に必須) + code_verifier: + type: string + description: PKCE code_verifier + + TokenResponse: + type: object + required: + - access_token + - token_type + - expires_in + properties: + access_token: + type: string + token_type: + type: string + enum: [Bearer] + expires_in: + type: integer + description: アクセストークンの有効期限 (秒) + refresh_token: + type: string + id_token: + type: string + description: ID トークン (scope に openid が含まれる場合) + scope: + type: string + description: 付与されたスコープ + + OAuthError: + type: object + required: + - error + properties: + error: + type: string + enum: + - invalid_request + - invalid_client + - invalid_grant + - unauthorized_client + - unsupported_grant_type + - invalid_scope + - access_denied + - server_error + error_description: + type: string + description: エラーの詳細説明 + + OpenIDConfiguration: + type: object + required: + - issuer + - authorization_endpoint + - token_endpoint + - userinfo_endpoint + - jwks_uri + - response_types_supported + - subject_types_supported + - id_token_signing_alg_values_supported + properties: + issuer: + type: string + format: uri + authorization_endpoint: + type: string + format: uri + token_endpoint: + type: string + format: uri + userinfo_endpoint: + type: string + format: uri + jwks_uri: + type: string + format: uri + response_types_supported: + type: array + items: + type: string + subject_types_supported: + type: array + items: + type: string + id_token_signing_alg_values_supported: + type: array + items: + type: string + scopes_supported: + type: array + items: + type: string + token_endpoint_auth_methods_supported: + type: array + items: + type: string + claims_supported: + type: array + items: + type: string + code_challenge_methods_supported: + type: array + items: + type: string + + JWKS: + type: object + required: + - keys + properties: + keys: + type: array + items: + $ref: "#/components/schemas/JWK" + + JWK: + type: object + required: + - kty + - kid + - use + properties: + kty: + type: string + description: Key Type (RSA, EC 等) + kid: + type: string + description: Key ID + use: + type: string + enum: [sig, enc] + alg: + type: string + description: Algorithm (RS256 等) + n: + type: string + description: RSA modulus (Base64url) + e: + type: string + description: RSA exponent (Base64url) + + UserInfo: + type: object + required: + - sub + properties: + sub: + type: string + description: Subject Identifier + name: + type: string + preferred_username: + type: string + email: + type: string + format: email + email_verified: + type: boolean + picture: + type: string + format: uri + updated_at: + type: integer + description: Unix timestamp + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/internal/router/v1/gen/models.go b/internal/router/v1/gen/models.go index e9b0aff..d0ba90d 100644 --- a/internal/router/v1/gen/models.go +++ b/internal/router/v1/gen/models.go @@ -9,12 +9,56 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + // Defines values for ClientType. const ( Confidential ClientType = "confidential" Public ClientType = "public" ) +// Defines values for JWKUse. +const ( + Enc JWKUse = "enc" + Sig JWKUse = "sig" +) + +// Defines values for OAuthErrorError. +const ( + AccessDenied OAuthErrorError = "access_denied" + InvalidClient OAuthErrorError = "invalid_client" + InvalidGrant OAuthErrorError = "invalid_grant" + InvalidRequest OAuthErrorError = "invalid_request" + InvalidScope OAuthErrorError = "invalid_scope" + ServerError OAuthErrorError = "server_error" + UnauthorizedClient OAuthErrorError = "unauthorized_client" + UnsupportedGrantType OAuthErrorError = "unsupported_grant_type" +) + +// Defines values for TokenRequestGrantType. +const ( + AuthorizationCode TokenRequestGrantType = "authorization_code" + RefreshToken TokenRequestGrantType = "refresh_token" +) + +// Defines values for TokenResponseTokenType. +const ( + Bearer TokenResponseTokenType = "Bearer" +) + +// Defines values for AuthorizeParamsResponseType. +const ( + Code AuthorizeParamsResponseType = "code" +) + +// Defines values for AuthorizeParamsCodeChallengeMethod. +const ( + Plain AuthorizeParamsCodeChallengeMethod = "plain" + S256 AuthorizeParamsCodeChallengeMethod = "S256" +) + // Client defines model for Client. type Client struct { ClientId openapi_types.UUID `json:"client_id"` @@ -72,8 +116,152 @@ type ClientWithSecret struct { UpdatedAt time.Time `json:"updated_at"` } +// JWK defines model for JWK. +type JWK struct { + // Alg Algorithm (RS256 等) + Alg *string `json:"alg,omitempty"` + + // E RSA exponent (Base64url) + E *string `json:"e,omitempty"` + + // Kid Key ID + Kid string `json:"kid"` + + // Kty Key Type (RSA, EC 等) + Kty string `json:"kty"` + + // N RSA modulus (Base64url) + N *string `json:"n,omitempty"` + Use JWKUse `json:"use"` +} + +// JWKUse defines model for JWK.Use. +type JWKUse string + +// JWKS defines model for JWKS. +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// OAuthError defines model for OAuthError. +type OAuthError struct { + Error OAuthErrorError `json:"error"` + + // ErrorDescription エラーの詳細説明 + ErrorDescription *string `json:"error_description,omitempty"` +} + +// OAuthErrorError defines model for OAuthError.Error. +type OAuthErrorError string + +// OpenIDConfiguration defines model for OpenIDConfiguration. +type OpenIDConfiguration struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + ClaimsSupported *[]string `json:"claims_supported,omitempty"` + CodeChallengeMethodsSupported *[]string `json:"code_challenge_methods_supported,omitempty"` + IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + Issuer string `json:"issuer"` + JwksUri string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + ScopesSupported *[]string `json:"scopes_supported,omitempty"` + SubjectTypesSupported []string `json:"subject_types_supported"` + TokenEndpoint string `json:"token_endpoint"` + TokenEndpointAuthMethodsSupported *[]string `json:"token_endpoint_auth_methods_supported,omitempty"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +// TokenRequest defines model for TokenRequest. +type TokenRequest struct { + ClientId *openapi_types.UUID `json:"client_id,omitempty"` + + // ClientSecret クライアントシークレット (confidential client の場合) + ClientSecret *string `json:"client_secret,omitempty"` + + // Code 認可コード (grant_type=authorization_code 時に必須) + Code *string `json:"code,omitempty"` + + // CodeVerifier PKCE code_verifier + CodeVerifier *string `json:"code_verifier,omitempty"` + GrantType TokenRequestGrantType `json:"grant_type"` + + // RedirectUri リダイレクトURI (grant_type=authorization_code 時に必須) + RedirectUri *string `json:"redirect_uri,omitempty"` + + // RefreshToken リフレッシュトークン (grant_type=refresh_token 時に必須) + RefreshToken *string `json:"refresh_token,omitempty"` +} + +// TokenRequestGrantType defines model for TokenRequest.GrantType. +type TokenRequestGrantType string + +// TokenResponse defines model for TokenResponse. +type TokenResponse struct { + AccessToken string `json:"access_token"` + + // ExpiresIn アクセストークンの有効期限 (秒) + ExpiresIn int `json:"expires_in"` + + // IdToken ID トークン (scope に openid が含まれる場合) + IdToken *string `json:"id_token,omitempty"` + RefreshToken *string `json:"refresh_token,omitempty"` + + // Scope 付与されたスコープ + Scope *string `json:"scope,omitempty"` + TokenType TokenResponseTokenType `json:"token_type"` +} + +// TokenResponseTokenType defines model for TokenResponse.TokenType. +type TokenResponseTokenType string + +// UserInfo defines model for UserInfo. +type UserInfo struct { + Email *openapi_types.Email `json:"email,omitempty"` + EmailVerified *bool `json:"email_verified,omitempty"` + Name *string `json:"name,omitempty"` + Picture *string `json:"picture,omitempty"` + PreferredUsername *string `json:"preferred_username,omitempty"` + + // Sub Subject Identifier + Sub string `json:"sub"` + + // UpdatedAt Unix timestamp + UpdatedAt *int `json:"updated_at,omitempty"` +} + +// AuthorizeParams defines parameters for Authorize. +type AuthorizeParams struct { + // ResponseType レスポンスタイプ (現在は code のみサポート) + ResponseType AuthorizeParamsResponseType `form:"response_type" json:"response_type"` + ClientId openapi_types.UUID `form:"client_id" json:"client_id"` + RedirectUri string `form:"redirect_uri" json:"redirect_uri"` + + // Scope スペース区切りのスコープ (openid, profile, email 等) + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` + + // State CSRF対策用のランダム文字列 + State *string `form:"state,omitempty" json:"state,omitempty"` + + // Nonce リプレイ攻撃対策用 (OIDC) + Nonce *string `form:"nonce,omitempty" json:"nonce,omitempty"` + + // CodeChallenge PKCE code_challenge + CodeChallenge *string `form:"code_challenge,omitempty" json:"code_challenge,omitempty"` + + // CodeChallengeMethod PKCE code_challenge_method + CodeChallengeMethod *AuthorizeParamsCodeChallengeMethod `form:"code_challenge_method,omitempty" json:"code_challenge_method,omitempty"` +} + +// AuthorizeParamsResponseType defines parameters for Authorize. +type AuthorizeParamsResponseType string + +// AuthorizeParamsCodeChallengeMethod defines parameters for Authorize. +type AuthorizeParamsCodeChallengeMethod string + // CreateClientJSONRequestBody defines body for CreateClient for application/json ContentType. type CreateClientJSONRequestBody = ClientCreate // UpdateClientJSONRequestBody defines body for UpdateClient for application/json ContentType. type UpdateClientJSONRequestBody = ClientUpdate + +// TokenFormdataRequestBody defines body for Token for application/x-www-form-urlencoded ContentType. +type TokenFormdataRequestBody = TokenRequest diff --git a/internal/router/v1/gen/server.go b/internal/router/v1/gen/server.go index 436a600..4070b95 100644 --- a/internal/router/v1/gen/server.go +++ b/internal/router/v1/gen/server.go @@ -17,6 +17,12 @@ import ( // ServerInterface represents all server handlers. type ServerInterface interface { + // JSON Web Key Set + // (GET /.well-known/jwks.json) + GetJWKS(ctx echo.Context) error + // OpenID Provider Configuration + // (GET /.well-known/openid-configuration) + GetOpenIDConfiguration(ctx echo.Context) error // クライアント一覧取得 // (GET /api/v1/admin/clients) GetClients(ctx echo.Context) error @@ -35,6 +41,18 @@ type ServerInterface interface { // クライアントシークレット再生成 // (POST /api/v1/admin/clients/{clientId}/secret) RegenerateClientSecret(ctx echo.Context, clientId openapi_types.UUID) error + // 認可エンドポイント + // (GET /oauth2/authorize) + Authorize(ctx echo.Context, params AuthorizeParams) error + // トークンエンドポイント + // (POST /oauth2/token) + Token(ctx echo.Context) error + // UserInfo エンドポイント (GET) + // (GET /oauth2/userinfo) + GetUserInfo(ctx echo.Context) error + // UserInfo エンドポイント (POST) + // (POST /oauth2/userinfo) + PostUserInfo(ctx echo.Context) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -42,6 +60,24 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetJWKS converts echo context to params. +func (w *ServerInterfaceWrapper) GetJWKS(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetJWKS(ctx) + return err +} + +// GetOpenIDConfiguration converts echo context to params. +func (w *ServerInterfaceWrapper) GetOpenIDConfiguration(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetOpenIDConfiguration(ctx) + return err +} + // GetClients converts echo context to params. func (w *ServerInterfaceWrapper) GetClients(ctx echo.Context) error { var err error @@ -124,6 +160,104 @@ func (w *ServerInterfaceWrapper) RegenerateClientSecret(ctx echo.Context) error return err } +// Authorize converts echo context to params. +func (w *ServerInterfaceWrapper) Authorize(ctx echo.Context) error { + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params AuthorizeParams + // ------------- Required query parameter "response_type" ------------- + + err = runtime.BindQueryParameter("form", true, true, "response_type", ctx.QueryParams(), ¶ms.ResponseType) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter response_type: %s", err)) + } + + // ------------- Required query parameter "client_id" ------------- + + err = runtime.BindQueryParameter("form", true, true, "client_id", ctx.QueryParams(), ¶ms.ClientId) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter client_id: %s", err)) + } + + // ------------- Required query parameter "redirect_uri" ------------- + + err = runtime.BindQueryParameter("form", true, true, "redirect_uri", ctx.QueryParams(), ¶ms.RedirectUri) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter redirect_uri: %s", err)) + } + + // ------------- Optional query parameter "scope" ------------- + + err = runtime.BindQueryParameter("form", true, false, "scope", ctx.QueryParams(), ¶ms.Scope) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter scope: %s", err)) + } + + // ------------- Optional query parameter "state" ------------- + + err = runtime.BindQueryParameter("form", true, false, "state", ctx.QueryParams(), ¶ms.State) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter state: %s", err)) + } + + // ------------- Optional query parameter "nonce" ------------- + + err = runtime.BindQueryParameter("form", true, false, "nonce", ctx.QueryParams(), ¶ms.Nonce) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter nonce: %s", err)) + } + + // ------------- Optional query parameter "code_challenge" ------------- + + err = runtime.BindQueryParameter("form", true, false, "code_challenge", ctx.QueryParams(), ¶ms.CodeChallenge) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter code_challenge: %s", err)) + } + + // ------------- Optional query parameter "code_challenge_method" ------------- + + err = runtime.BindQueryParameter("form", true, false, "code_challenge_method", ctx.QueryParams(), ¶ms.CodeChallengeMethod) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter code_challenge_method: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.Authorize(ctx, params) + return err +} + +// Token converts echo context to params. +func (w *ServerInterfaceWrapper) Token(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.Token(ctx) + return err +} + +// GetUserInfo converts echo context to params. +func (w *ServerInterfaceWrapper) GetUserInfo(ctx echo.Context) error { + var err error + + ctx.Set(BearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetUserInfo(ctx) + return err +} + +// PostUserInfo converts echo context to params. +func (w *ServerInterfaceWrapper) PostUserInfo(ctx echo.Context) error { + var err error + + ctx.Set(BearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.PostUserInfo(ctx) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -152,13 +286,51 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/.well-known/jwks.json", wrapper.GetJWKS) + router.GET(baseURL+"/.well-known/openid-configuration", wrapper.GetOpenIDConfiguration) router.GET(baseURL+"/api/v1/admin/clients", wrapper.GetClients) router.POST(baseURL+"/api/v1/admin/clients", wrapper.CreateClient) router.DELETE(baseURL+"/api/v1/admin/clients/:clientId", wrapper.DeleteClient) router.GET(baseURL+"/api/v1/admin/clients/:clientId", wrapper.GetClient) router.PUT(baseURL+"/api/v1/admin/clients/:clientId", wrapper.UpdateClient) router.POST(baseURL+"/api/v1/admin/clients/:clientId/secret", wrapper.RegenerateClientSecret) + router.GET(baseURL+"/oauth2/authorize", wrapper.Authorize) + router.POST(baseURL+"/oauth2/token", wrapper.Token) + router.GET(baseURL+"/oauth2/userinfo", wrapper.GetUserInfo) + router.POST(baseURL+"/oauth2/userinfo", wrapper.PostUserInfo) + +} + +type GetJWKSRequestObject struct { +} + +type GetJWKSResponseObject interface { + VisitGetJWKSResponse(w http.ResponseWriter) error +} + +type GetJWKS200JSONResponse JWKS + +func (response GetJWKS200JSONResponse) VisitGetJWKSResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetOpenIDConfigurationRequestObject struct { +} +type GetOpenIDConfigurationResponseObject interface { + VisitGetOpenIDConfigurationResponse(w http.ResponseWriter) error +} + +type GetOpenIDConfiguration200JSONResponse OpenIDConfiguration + +func (response GetOpenIDConfiguration200JSONResponse) VisitGetOpenIDConfigurationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) } type GetClientsRequestObject struct { @@ -358,8 +530,122 @@ func (response RegenerateClientSecret404Response) VisitRegenerateClientSecretRes return nil } +type AuthorizeRequestObject struct { + Params AuthorizeParams +} + +type AuthorizeResponseObject interface { + VisitAuthorizeResponse(w http.ResponseWriter) error +} + +type Authorize302Response struct { +} + +func (response Authorize302Response) VisitAuthorizeResponse(w http.ResponseWriter) error { + w.WriteHeader(302) + return nil +} + +type Authorize400JSONResponse OAuthError + +func (response Authorize400JSONResponse) VisitAuthorizeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type TokenRequestObject struct { + Body *TokenFormdataRequestBody +} + +type TokenResponseObject interface { + VisitTokenResponse(w http.ResponseWriter) error +} + +type Token200JSONResponse TokenResponse + +func (response Token200JSONResponse) VisitTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type Token400JSONResponse OAuthError + +func (response Token400JSONResponse) VisitTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type Token401JSONResponse OAuthError + +func (response Token401JSONResponse) VisitTokenResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserInfoRequestObject struct { +} + +type GetUserInfoResponseObject interface { + VisitGetUserInfoResponse(w http.ResponseWriter) error +} + +type GetUserInfo200JSONResponse UserInfo + +func (response GetUserInfo200JSONResponse) VisitGetUserInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetUserInfo401Response struct { +} + +func (response GetUserInfo401Response) VisitGetUserInfoResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + +type PostUserInfoRequestObject struct { +} + +type PostUserInfoResponseObject interface { + VisitPostUserInfoResponse(w http.ResponseWriter) error +} + +type PostUserInfo200JSONResponse UserInfo + +func (response PostUserInfo200JSONResponse) VisitPostUserInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PostUserInfo401Response struct { +} + +func (response PostUserInfo401Response) VisitPostUserInfoResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // JSON Web Key Set + // (GET /.well-known/jwks.json) + GetJWKS(ctx context.Context, request GetJWKSRequestObject) (GetJWKSResponseObject, error) + // OpenID Provider Configuration + // (GET /.well-known/openid-configuration) + GetOpenIDConfiguration(ctx context.Context, request GetOpenIDConfigurationRequestObject) (GetOpenIDConfigurationResponseObject, error) // クライアント一覧取得 // (GET /api/v1/admin/clients) GetClients(ctx context.Context, request GetClientsRequestObject) (GetClientsResponseObject, error) @@ -378,6 +664,18 @@ type StrictServerInterface interface { // クライアントシークレット再生成 // (POST /api/v1/admin/clients/{clientId}/secret) RegenerateClientSecret(ctx context.Context, request RegenerateClientSecretRequestObject) (RegenerateClientSecretResponseObject, error) + // 認可エンドポイント + // (GET /oauth2/authorize) + Authorize(ctx context.Context, request AuthorizeRequestObject) (AuthorizeResponseObject, error) + // トークンエンドポイント + // (POST /oauth2/token) + Token(ctx context.Context, request TokenRequestObject) (TokenResponseObject, error) + // UserInfo エンドポイント (GET) + // (GET /oauth2/userinfo) + GetUserInfo(ctx context.Context, request GetUserInfoRequestObject) (GetUserInfoResponseObject, error) + // UserInfo エンドポイント (POST) + // (POST /oauth2/userinfo) + PostUserInfo(ctx context.Context, request PostUserInfoRequestObject) (PostUserInfoResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -392,6 +690,52 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetJWKS operation middleware +func (sh *strictHandler) GetJWKS(ctx echo.Context) error { + var request GetJWKSRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetJWKS(ctx.Request().Context(), request.(GetJWKSRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetJWKS") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetJWKSResponseObject); ok { + return validResponse.VisitGetJWKSResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetOpenIDConfiguration operation middleware +func (sh *strictHandler) GetOpenIDConfiguration(ctx echo.Context) error { + var request GetOpenIDConfigurationRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetOpenIDConfiguration(ctx.Request().Context(), request.(GetOpenIDConfigurationRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetOpenIDConfiguration") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetOpenIDConfigurationResponseObject); ok { + return validResponse.VisitGetOpenIDConfigurationResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // GetClients operation middleware func (sh *strictHandler) GetClients(ctx echo.Context) error { var request GetClientsRequestObject @@ -549,3 +893,107 @@ func (sh *strictHandler) RegenerateClientSecret(ctx echo.Context, clientId opena } return nil } + +// Authorize operation middleware +func (sh *strictHandler) Authorize(ctx echo.Context, params AuthorizeParams) error { + var request AuthorizeRequestObject + + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.Authorize(ctx.Request().Context(), request.(AuthorizeRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Authorize") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(AuthorizeResponseObject); ok { + return validResponse.VisitAuthorizeResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// Token operation middleware +func (sh *strictHandler) Token(ctx echo.Context) error { + var request TokenRequestObject + + if form, err := ctx.FormParams(); err == nil { + var body TokenFormdataRequestBody + if err := runtime.BindForm(&body, form, nil, nil); err != nil { + return err + } + request.Body = &body + } else { + return err + } + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.Token(ctx.Request().Context(), request.(TokenRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Token") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(TokenResponseObject); ok { + return validResponse.VisitTokenResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetUserInfo operation middleware +func (sh *strictHandler) GetUserInfo(ctx echo.Context) error { + var request GetUserInfoRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetUserInfo(ctx.Request().Context(), request.(GetUserInfoRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetUserInfo") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetUserInfoResponseObject); ok { + return validResponse.VisitGetUserInfoResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// PostUserInfo operation middleware +func (sh *strictHandler) PostUserInfo(ctx echo.Context) error { + var request PostUserInfoRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.PostUserInfo(ctx.Request().Context(), request.(PostUserInfoRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PostUserInfo") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(PostUserInfoResponseObject); ok { + return validResponse.VisitPostUserInfoResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} From aa4c5dfbb67dce41cb8d9c2357ccd38a8e26e0f3 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:53:41 +0900 Subject: [PATCH 08/26] feat: implement OAuth2/OIDC endpoints --- cmd/oauth.go | 191 ++++++++++ internal/repository/oauth_storage.go | 444 +++++++++++++++++++++++ internal/router/v1/auth.go | 114 ++++++ internal/router/v1/handler.go | 38 +- internal/router/v1/handler_test.go | 510 +++++++++++++++++++++++++++ internal/router/v1/oauth.go | 166 +++++++++ 6 files changed, 1461 insertions(+), 2 deletions(-) create mode 100644 cmd/oauth.go create mode 100644 internal/repository/oauth_storage.go create mode 100644 internal/router/v1/auth.go create mode 100644 internal/router/v1/handler_test.go create mode 100644 internal/router/v1/oauth.go diff --git a/cmd/oauth.go b/cmd/oauth.go new file mode 100644 index 0000000..e25bee7 --- /dev/null +++ b/cmd/oauth.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "io" + "os" + "path/filepath" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + "github.com/ory/fosite/token/jwt" + + "github.com/traPtitech/portal-oidc/internal/repository" +) + +type OAuthProviderConfig struct { + Issuer string + AccessTokenLifespan time.Duration + RefreshTokenLifespan time.Duration + AuthCodeLifespan time.Duration + IDTokenLifespan time.Duration + Secret []byte +} + +func defaultOAuthProviderConfig() OAuthProviderConfig { + return OAuthProviderConfig{ + Issuer: "http://localhost:8080", + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: 30 * 24 * time.Hour, + AuthCodeLifespan: 5 * time.Minute, + IDTokenLifespan: time.Hour, + Secret: []byte("my-super-secret-signing-key-32!!"), + } +} + +func newOAuthProvider(storage *repository.OAuthStorage, config OAuthProviderConfig, privateKey *rsa.PrivateKey) fosite.OAuth2Provider { + fositeConfig := &fosite.Config{ + AccessTokenLifespan: config.AccessTokenLifespan, + RefreshTokenLifespan: config.RefreshTokenLifespan, + AuthorizeCodeLifespan: config.AuthCodeLifespan, + IDTokenLifespan: config.IDTokenLifespan, + GlobalSecret: config.Secret, + ScopeStrategy: fosite.ExactScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + SendDebugMessagesToClients: false, + EnforcePKCE: false, + EnforcePKCEForPublicClients: false, + EnablePKCEPlainChallengeMethod: false, + AccessTokenIssuer: config.Issuer, + IDTokenIssuer: config.Issuer, + } + + privateKeyGetter := func(_ context.Context) (interface{}, error) { + return privateKey, nil + } + + return compose.Compose( + fositeConfig, + storage, + &compose.CommonStrategy{ + CoreStrategy: compose.NewOAuth2HMACStrategy(fositeConfig), + Signer: &jwt.DefaultSigner{GetPrivateKey: privateKeyGetter}, + OpenIDConnectTokenStrategy: compose.NewOpenIDConnectStrategy(privateKeyGetter, fositeConfig), + }, + + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2PKCEFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.OAuth2TokenIntrospectionFactory, + compose.OAuth2TokenRevocationFactory, + compose.OpenIDConnectExplicitFactory, + compose.OpenIDConnectRefreshFactory, + ) +} + +func loadOrGenerateKey(path string) (*rsa.PrivateKey, error) { + key, err := loadKey(path) + if err == nil { + return key, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + key, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + if err := saveKey(path, key); err != nil { + return nil, err + } + + return key, nil +} + +func loadKey(path string) (key *rsa.PrivateKey, err error) { + root, filename, err := openKeyRoot(path) + if err != nil { + return nil, err + } + defer func() { + if cerr := root.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + f, err := root.Open(filename) + if err != nil { + return nil, err + } + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + data, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("failed to decode PEM") + } + + return x509.ParsePKCS1PrivateKey(block.Bytes) +} + +func openKeyRoot(path string) (*os.Root, string, error) { + cleanPath := filepath.Clean(path) + dir := filepath.Dir(cleanPath) + filename := filepath.Base(cleanPath) + + root, err := os.OpenRoot(dir) + if err != nil { + return nil, "", err + } + + return root, filename, nil +} + +func saveKey(path string, key *rsa.PrivateKey) (err error) { + cleanPath := filepath.Clean(path) + dir := filepath.Dir(cleanPath) + filename := filepath.Base(cleanPath) + + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer func() { + if cerr := root.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + data := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + f, err := root.Create(filename) + if err != nil { + return err + } + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = cerr + } + }() + + if err := f.Chmod(0o600); err != nil { + return err + } + + _, err = f.Write(data) + return err +} diff --git a/internal/repository/oauth_storage.go b/internal/repository/oauth_storage.go new file mode 100644 index 0000000..73093e0 --- /dev/null +++ b/internal/repository/oauth_storage.go @@ -0,0 +1,444 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + "github.com/google/uuid" + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/token/jwt" + "golang.org/x/crypto/bcrypt" + + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +// OAuthStorage implements fosite.Storage interface +type OAuthStorage struct { + queries *oidc.Queries + oidcSessions map[string]fosite.Requester + oidcSessionsMutex sync.RWMutex +} + +func NewOAuthStorage(queries *oidc.Queries) *OAuthStorage { + return &OAuthStorage{ + queries: queries, + oidcSessions: make(map[string]fosite.Requester), + } +} + +// ClientCredentials implements fosite.ClientCredentialsStorage +func (s *OAuthStorage) GetClient(ctx context.Context, id string) (fosite.Client, error) { + dbClient, err := s.queries.GetClient(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + var redirectURIs []string + if err := json.Unmarshal(dbClient.RedirectUris, &redirectURIs); err != nil { + return nil, err + } + + return &OAuthClient{ + ID: dbClient.ClientID, + Secret: []byte(dbClient.ClientSecretHash.String), + RedirectURIs: redirectURIs, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scopes: []string{"openid", "profile", "email"}, + Public: dbClient.ClientType == "public", + }, nil +} + +func (s *OAuthStorage) ClientAssertionJWTValid(ctx context.Context, jti string) error { + return fosite.ErrNotFound +} + +func (s *OAuthStorage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { + return nil +} + +// AuthorizeCodeStorage implements fosite.AuthorizeCodeStorage +func (s *OAuthStorage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { + sess, ok := request.GetSession().(*OAuthSession) + if !ok { + return errors.New("invalid session type") + } + + return s.queries.CreateAuthorizationCode(ctx, oidc.CreateAuthorizationCodeParams{ + Code: code, + ClientID: request.GetClient().GetID(), + UserID: sess.Subject, + RedirectUri: request.GetRequestForm().Get("redirect_uri"), + Scopes: strings.Join(request.GetRequestedScopes(), " "), + CodeChallenge: sql.NullString{Valid: false}, + CodeChallengeMethod: sql.NullString{Valid: false}, + Nonce: sql.NullString{ + String: request.GetRequestForm().Get("nonce"), + Valid: request.GetRequestForm().Get("nonce") != "", + }, + ExpiresAt: sess.ExpiresAt[fosite.AuthorizeCode], + }) +} + +func (s *OAuthStorage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) { + dbCode, err := s.queries.GetAuthorizationCode(ctx, code) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + if time.Now().After(dbCode.ExpiresAt) { + return nil, fosite.ErrTokenExpired + } + + client, err := s.GetClient(ctx, dbCode.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbCode.Scopes, " ") + if dbCode.Scopes == "" { + scopes = []string{} + } + + sess := NewOAuthSession(dbCode.UserID) + sess.ExpiresAt[fosite.AuthorizeCode] = dbCode.ExpiresAt + + form := make(map[string][]string) + form["redirect_uri"] = []string{dbCode.RedirectUri} + if dbCode.CodeChallenge.Valid { + form["code_challenge"] = []string{dbCode.CodeChallenge.String} + } + if dbCode.CodeChallengeMethod.Valid { + form["code_challenge_method"] = []string{dbCode.CodeChallengeMethod.String} + } + if dbCode.Nonce.Valid { + form["nonce"] = []string{dbCode.Nonce.String} + } + + req := &fosite.Request{ + ID: code, + RequestedAt: dbCode.CreatedAt, + Client: client, + Form: form, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *OAuthStorage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error { + return s.queries.DeleteAuthorizationCode(ctx, code) +} + +// PKCERequestStorage implements fosite.PKCERequestStorage +func (s *OAuthStorage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + return s.GetAuthorizeCodeSession(ctx, signature, session) +} + +func (s *OAuthStorage) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error { + challenge := requester.GetRequestForm().Get("code_challenge") + method := requester.GetRequestForm().Get("code_challenge_method") + + if challenge == "" { + return nil + } + + return s.queries.UpdateAuthorizationCodePKCE(ctx, oidc.UpdateAuthorizationCodePKCEParams{ + CodeChallenge: sql.NullString{ + String: challenge, + Valid: true, + }, + CodeChallengeMethod: sql.NullString{ + String: method, + Valid: method != "", + }, + Code: signature, + }) +} + +func (s *OAuthStorage) DeletePKCERequestSession(ctx context.Context, signature string) error { + return nil +} + +// AccessTokenStorage implements fosite.AccessTokenStorage +func (s *OAuthStorage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { + sess, ok := request.GetSession().(*OAuthSession) + if !ok { + return errors.New("invalid session type") + } + + tokenID := uuid.New() + return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ + ID: tokenID.String(), + ClientID: request.GetClient().GetID(), + UserID: sess.Subject, + AccessToken: signature, + RefreshToken: sql.NullString{ + Valid: false, + }, + Scopes: strings.Join(request.GetGrantedScopes(), " "), + ExpiresAt: sess.ExpiresAt[fosite.AccessToken], + }) +} + +func (s *OAuthStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + dbToken, err := s.queries.GetTokenByAccessToken(ctx, signature) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + if time.Now().After(dbToken.ExpiresAt) { + return nil, fosite.ErrTokenExpired + } + + client, err := s.GetClient(ctx, dbToken.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbToken.Scopes, " ") + if dbToken.Scopes == "" { + scopes = []string{} + } + + sess := NewOAuthSession(dbToken.UserID) + sess.ExpiresAt[fosite.AccessToken] = dbToken.ExpiresAt + + req := &fosite.Request{ + ID: dbToken.ID, + RequestedAt: dbToken.CreatedAt, + Client: client, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *OAuthStorage) DeleteAccessTokenSession(ctx context.Context, signature string) error { + return s.queries.DeleteTokenByAccessToken(ctx, signature) +} + +// RefreshTokenStorage implements fosite.RefreshTokenStorage +func (s *OAuthStorage) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error { + sess, ok := request.GetSession().(*OAuthSession) + if !ok { + return errors.New("invalid session type") + } + + tokenID := uuid.New() + return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ + ID: tokenID.String(), + ClientID: request.GetClient().GetID(), + UserID: sess.Subject, + AccessToken: accessSignature, + RefreshToken: sql.NullString{ + String: signature, + Valid: true, + }, + Scopes: strings.Join(request.GetGrantedScopes(), " "), + ExpiresAt: sess.ExpiresAt[fosite.RefreshToken], + }) +} + +func (s *OAuthStorage) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error { + return nil +} + +func (s *OAuthStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + dbToken, err := s.queries.GetTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + client, err := s.GetClient(ctx, dbToken.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbToken.Scopes, " ") + if dbToken.Scopes == "" { + scopes = []string{} + } + + sess := NewOAuthSession(dbToken.UserID) + sess.ExpiresAt[fosite.RefreshToken] = dbToken.ExpiresAt + + req := &fosite.Request{ + ID: dbToken.ID, + RequestedAt: dbToken.CreatedAt, + Client: client, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *OAuthStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) error { + return s.queries.DeleteTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) +} + +func (s *OAuthStorage) RevokeRefreshToken(ctx context.Context, requestID string) error { + return nil +} + +func (s *OAuthStorage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error { + return s.DeleteRefreshTokenSession(ctx, signature) +} + +func (s *OAuthStorage) RevokeAccessToken(ctx context.Context, requestID string) error { + return nil +} + +// OpenIDConnectRequestStorage implementation + +func (s *OAuthStorage) CreateOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) error { + s.oidcSessionsMutex.Lock() + defer s.oidcSessionsMutex.Unlock() + s.oidcSessions[authorizeCode] = requester + return nil +} + +func (s *OAuthStorage) GetOpenIDConnectSession(_ context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { + s.oidcSessionsMutex.RLock() + defer s.oidcSessionsMutex.RUnlock() + req, ok := s.oidcSessions[authorizeCode] + if !ok { + return nil, fosite.ErrNotFound + } + return req, nil +} + +func (s *OAuthStorage) DeleteOpenIDConnectSession(_ context.Context, authorizeCode string) error { + s.oidcSessionsMutex.Lock() + defer s.oidcSessionsMutex.Unlock() + delete(s.oidcSessions, authorizeCode) + return nil +} + +// OAuthClient implements fosite.Client +type OAuthClient struct { + ID string + Secret []byte + RedirectURIs []string + GrantTypes []string + ResponseTypes []string + Scopes []string + Public bool +} + +func (c *OAuthClient) GetID() string { return c.ID } +func (c *OAuthClient) GetHashedSecret() []byte { return c.Secret } +func (c *OAuthClient) GetRedirectURIs() []string { return c.RedirectURIs } +func (c *OAuthClient) GetGrantTypes() fosite.Arguments { return c.GrantTypes } +func (c *OAuthClient) GetResponseTypes() fosite.Arguments { + if len(c.ResponseTypes) == 0 { + return []string{"code"} + } + return c.ResponseTypes +} +func (c *OAuthClient) GetScopes() fosite.Arguments { return c.Scopes } +func (c *OAuthClient) IsPublic() bool { return c.Public } +func (c *OAuthClient) GetAudience() fosite.Arguments { return nil } + +// OAuthSession implements fosite.Session and openid.Session +type OAuthSession struct { + Subject string + Username string + ExpiresAt map[fosite.TokenType]time.Time + Extra map[string]interface{} + idTokenClaims *jwt.IDTokenClaims + idTokenHeaders *jwt.Headers +} + +var _ openid.Session = (*OAuthSession)(nil) + +func NewOAuthSession(subject string) *OAuthSession { + return &OAuthSession{ + Subject: subject, + Username: subject, + ExpiresAt: make(map[fosite.TokenType]time.Time), + Extra: make(map[string]interface{}), + idTokenClaims: &jwt.IDTokenClaims{ + Subject: subject, + }, + idTokenHeaders: &jwt.Headers{ + Extra: make(map[string]interface{}), + }, + } +} + +func (s *OAuthSession) SetExpiresAt(key fosite.TokenType, exp time.Time) { + if s.ExpiresAt == nil { + s.ExpiresAt = make(map[fosite.TokenType]time.Time) + } + s.ExpiresAt[key] = exp +} + +func (s *OAuthSession) GetExpiresAt(key fosite.TokenType) time.Time { + if s.ExpiresAt == nil { + return time.Time{} + } + return s.ExpiresAt[key] +} + +func (s *OAuthSession) GetUsername() string { return s.Username } +func (s *OAuthSession) GetSubject() string { return s.Subject } +func (s *OAuthSession) IDTokenClaims() *jwt.IDTokenClaims { return s.idTokenClaims } +func (s *OAuthSession) IDTokenHeaders() *jwt.Headers { return s.idTokenHeaders } + +func (s *OAuthSession) Clone() fosite.Session { + expiresAt := make(map[fosite.TokenType]time.Time) + for k, v := range s.ExpiresAt { + expiresAt[k] = v + } + extra := make(map[string]interface{}) + for k, v := range s.Extra { + extra[k] = v + } + idTokenClaimsClone := *s.idTokenClaims + idTokenHeadersClone := *s.idTokenHeaders + idTokenHeadersClone.Extra = make(map[string]interface{}) + for k, v := range s.idTokenHeaders.Extra { + idTokenHeadersClone.Extra[k] = v + } + return &OAuthSession{ + Subject: s.Subject, + Username: s.Username, + ExpiresAt: expiresAt, + Extra: extra, + idTokenClaims: &idTokenClaimsClone, + idTokenHeaders: &idTokenHeadersClone, + } +} + +// ValidateClientSecret validates the client secret using bcrypt +func ValidateClientSecret(hashedSecret []byte, secret string) bool { + return bcrypt.CompareHashAndPassword(hashedSecret, []byte(secret)) == nil +} diff --git a/internal/router/v1/auth.go b/internal/router/v1/auth.go new file mode 100644 index 0000000..7f750df --- /dev/null +++ b/internal/router/v1/auth.go @@ -0,0 +1,114 @@ +package v1 + +import ( + "html" + "net/http" + + "github.com/labstack/echo/v4" +) + +const sessionName = "oidc_session" + +func (h *Handler) GetLogin(ctx echo.Context) error { + returnURL := ctx.QueryParam("return_url") + if returnURL == "" { + returnURL = "/" + } + + page := ` + + + Login + + + +

Login

+
+ + + + +
+

Test user: testuser / password

+ +` + + return ctx.HTML(http.StatusOK, page) +} + +func (h *Handler) PostLogin(ctx echo.Context) error { + username := ctx.FormValue("username") + password := ctx.FormValue("password") + returnURL := ctx.FormValue("return_url") + + if username != "testuser" || password != "password" { + return ctx.HTML(http.StatusUnauthorized, ` + +Login Failed + +

Login Failed

+

Invalid username or password.

+ Try again + +`) + } + + session, err := h.sessions.Get(ctx.Request(), sessionName) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get session") + } + + session.Values["user_id"] = username + session.Values["authenticated"] = true + + if err := session.Save(ctx.Request(), ctx.Response()); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to save session") + } + + if returnURL == "" { + returnURL = "/" + } + return ctx.Redirect(http.StatusFound, returnURL) +} + +func (h *Handler) Logout(ctx echo.Context) error { + session, err := h.sessions.Get(ctx.Request(), sessionName) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get session") + } + + session.Values["user_id"] = nil + session.Values["authenticated"] = false + session.Options.MaxAge = -1 + + if err := session.Save(ctx.Request(), ctx.Response()); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to save session") + } + + return ctx.Redirect(http.StatusFound, "/") +} + +func (h *Handler) getAuthenticatedUser(ctx echo.Context) string { + session, err := h.sessions.Get(ctx.Request(), sessionName) + if err != nil { + return "" + } + + authenticated, ok := session.Values["authenticated"].(bool) + if !ok || !authenticated { + return "" + } + + userID, ok := session.Values["user_id"].(string) + if !ok { + return "" + } + + return userID +} diff --git a/internal/router/v1/handler.go b/internal/router/v1/handler.go index 6386d44..da22374 100644 --- a/internal/router/v1/handler.go +++ b/internal/router/v1/handler.go @@ -1,13 +1,47 @@ package v1 -import "github.com/traPtitech/portal-oidc/internal/usecase" +import ( + "crypto/rsa" + "net/http" + + "github.com/gorilla/sessions" + "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/usecase" +) type Handler struct { clientUseCase usecase.ClientUseCase + oauth2 fosite.OAuth2Provider + sessions *sessions.CookieStore + config OAuthConfig +} + +type OAuthConfig struct { + Issuer string + SessionSecret []byte + PrivateKey *rsa.PrivateKey + Environment string + TestUserID string } -func NewHandler(clientUseCase usecase.ClientUseCase) *Handler { +func NewHandler( + clientUseCase usecase.ClientUseCase, + oauth2 fosite.OAuth2Provider, + config OAuthConfig, +) *Handler { + store := sessions.NewCookieStore(config.SessionSecret) + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 3600, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + return &Handler{ clientUseCase: clientUseCase, + oauth2: oauth2, + sessions: store, + config: config, } } diff --git a/internal/router/v1/handler_test.go b/internal/router/v1/handler_test.go new file mode 100644 index 0000000..2e05fec --- /dev/null +++ b/internal/router/v1/handler_test.go @@ -0,0 +1,510 @@ +package v1 + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "github.com/labstack/echo/v4" + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + + "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" + "github.com/traPtitech/portal-oidc/internal/router/v1/gen" + "github.com/traPtitech/portal-oidc/internal/testutil" + "github.com/traPtitech/portal-oidc/internal/usecase" +) + +const ( + testDBName = "oidc_test" +) + +var testDB *sql.DB + +func TestMain(m *testing.M) { + k := koanf.New(".") + ctx := context.Background() + + // デフォルト値 + _ = k.Load(confmap.Provider(map[string]any{ + "mariadb.username": "root", + "mariadb.password": "password", + "mariadb.hostname": "127.0.0.1", + "mariadb.port": "3307", + }, "."), nil) + + // 環境変数で上書き (MARIADB_USERNAME など) + _ = k.Load(env.Provider("MARIADB_", ".", func(s string) string { + return strings.ToLower(strings.TrimPrefix(s, "MARIADB_")) + }), nil) + + user := k.String("mariadb.username") + pass := k.String("mariadb.password") + host := k.String("mariadb.hostname") + port := k.String("mariadb.port") + + // Connect without database to create test database + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?parseTime=true", user, pass, host, port) + db, err := sql.Open("mysql", dsn) + if err != nil { + fmt.Printf("failed to connect to database: %v\n", err) + os.Exit(1) + } + + if err := db.PingContext(ctx); err != nil { + fmt.Printf("failed to ping database: %v\n", err) + os.Exit(1) + } + + // Create test database + _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", testDBName)) + if err != nil { + fmt.Printf("failed to create test database: %v\n", err) + os.Exit(1) + } + _ = db.Close() + + // Connect to test database + dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&multiStatements=true", user, pass, host, port, testDBName) + testDB, err = sql.Open("mysql", dsn) + if err != nil { + fmt.Printf("failed to connect to test database: %v\n", err) + os.Exit(1) + } + + // Load and execute schema + root, err := testutil.FindProjectRoot() + if err != nil { + fmt.Printf("failed to find project root: %v\n", err) + os.Exit(1) + } + schemaPath := filepath.Join(root, "db", "schema.sql") + schemaSQL, err := os.ReadFile(schemaPath) //nolint:gosec + if err != nil { + fmt.Printf("failed to read schema file: %v\n", err) + os.Exit(1) + } + + _, err = testDB.ExecContext(ctx, string(schemaSQL)) + if err != nil { + fmt.Printf("failed to create schema: %v\n", err) + os.Exit(1) + } + + // Run tests + code := m.Run() + + // Cleanup + _ = testDB.Close() + + os.Exit(code) +} + +func setupTestHandler(t *testing.T) (*Handler, func()) { + t.Helper() + + ctx := context.Background() + + queries, err := oidc.Prepare(ctx, testDB) + if err != nil { + t.Fatalf("failed to prepare queries: %v", err) + } + + // Clean up tables before test + if err := queries.DeleteAllClients(ctx); err != nil { + t.Fatalf("failed to clean up clients table: %v", err) + } + if err := queries.DeleteAllAuthorizationCodes(ctx); err != nil { + t.Fatalf("failed to clean up authorization_codes table: %v", err) + } + if err := queries.DeleteAllTokens(ctx); err != nil { + t.Fatalf("failed to clean up tokens table: %v", err) + } + + clientRepo := repository.NewClientRepository(queries) + clientUseCase := usecase.NewClientUseCase(clientRepo) + + // Create OAuth2 provider for testing + oauthStorage := repository.NewOAuthStorage(queries) + fositeConfig := &fosite.Config{ + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: 30 * 24 * time.Hour, + AuthorizeCodeLifespan: 5 * time.Minute, + IDTokenLifespan: time.Hour, + GlobalSecret: []byte("test-secret-key-32-characters!!"), + ScopeStrategy: fosite.ExactScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + SendDebugMessagesToClients: false, + EnforcePKCE: true, + EnforcePKCEForPublicClients: true, + EnablePKCEPlainChallengeMethod: true, + AccessTokenIssuer: "http://localhost:8080", + IDTokenIssuer: "http://localhost:8080", + } + oauth2Provider := compose.Compose( + fositeConfig, + oauthStorage, + compose.NewOAuth2HMACStrategy(fositeConfig), + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2PKCEFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.OAuth2TokenIntrospectionFactory, + compose.OAuth2TokenRevocationFactory, + ) + + handler := NewHandler(clientUseCase, oauth2Provider, OAuthConfig{ + Issuer: "http://localhost:8080", + }) + + cleanup := func() { + _ = queries.DeleteAllTokens(ctx) + _ = queries.DeleteAllAuthorizationCodes(ctx) + _ = queries.DeleteAllClients(ctx) + _ = queries.Close() + } + + return handler, cleanup +} + +func TestIntegration_CreateClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + reqBody := `{"name":"integration-test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var resp gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.Name != "integration-test-client" { + t.Errorf("Name = %q, want %q", resp.Name, "integration-test-client") + } + if resp.ClientType != gen.Confidential { + t.Errorf("ClientType = %q, want %q", resp.ClientType, gen.Confidential) + } + if resp.ClientSecret == "" { + t.Error("ClientSecret should not be empty") + } + if len(resp.RedirectUris) != 1 { + t.Errorf("len(RedirectUris) = %d, want 1", len(resp.RedirectUris)) + } +} + +func TestIntegration_GetClients(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // Create a client first + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // Get clients list + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var clients []gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(clients) != 1 { + t.Errorf("len(clients) = %d, want 1", len(clients)) + } +} + +func TestIntegration_GetClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // Create a client + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Get client by ID + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var client gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &client); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if client.ClientId != created.ClientId { + t.Errorf("ClientId = %s, want %s", client.ClientId, created.ClientId) + } + if client.Name != "test-client" { + t.Errorf("Name = %q, want %q", client.Name, "test-client") + } +} + +func TestIntegration_GetClient_NotFound(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/00000000-0000-0000-0000-000000000000", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestIntegration_UpdateClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // Create a client + reqBody := `{"name":"original","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Update client + updateBody := `{"name":"updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var updated gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if updated.Name != "updated" { + t.Errorf("Name = %q, want %q", updated.Name, "updated") + } + if updated.ClientType != gen.Public { + t.Errorf("ClientType = %q, want %q", updated.ClientType, gen.Public) + } + if updated.RedirectUris[0] != "http://localhost:4000/callback" { + t.Errorf("RedirectUris[0] = %q, want %q", updated.RedirectUris[0], "http://localhost:4000/callback") + } +} + +func TestIntegration_DeleteClient(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // Create a client + reqBody := `{"name":"to-delete","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Delete client + req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + + // Verify deletion + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestIntegration_RegenerateClientSecret(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // Create a client + reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Regenerate secret + req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var secret gen.ClientSecret + if err := json.Unmarshal(rec.Body.Bytes(), &secret); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if secret.ClientSecret == "" { + t.Error("ClientSecret should not be empty") + } + if secret.ClientSecret == created.ClientSecret { + t.Error("new secret should be different from original") + } +} + +func TestIntegration_FullWorkflow(t *testing.T) { + handler, cleanup := setupTestHandler(t) + defer cleanup() + + e := echo.New() + gen.RegisterHandlers(e, handler) + + // 1. Create client + createBody := `{"name":"workflow-test","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(createBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("Create: status = %d, want %d", rec.Code, http.StatusCreated) + } + + var created gen.ClientWithSecret + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // 2. Verify in list + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + var clients []gen.Client + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(clients) != 1 { + t.Errorf("List: len = %d, want 1", len(clients)) + } + + // 3. Update client + updateBody := `{"name":"workflow-updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` + req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Update: status = %d, want %d", rec.Code, http.StatusOK) + } + + // 4. Regenerate secret + req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("RegenerateSecret: status = %d, want %d", rec.Code, http.StatusOK) + } + + // 5. Delete client + req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("Delete: status = %d, want %d", rec.Code, http.StatusNoContent) + } + + // 6. Verify list is empty + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(clients) != 0 { + t.Errorf("Final List: len = %d, want 0", len(clients)) + } +} diff --git a/internal/router/v1/oauth.go b/internal/router/v1/oauth.go new file mode 100644 index 0000000..1fa3cd8 --- /dev/null +++ b/internal/router/v1/oauth.go @@ -0,0 +1,166 @@ +package v1 + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/go-jose/go-jose/v4" + "github.com/labstack/echo/v4" + "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/router/v1/gen" +) + +func (h *Handler) Authorize(ctx echo.Context, params gen.AuthorizeParams) error { + c := ctx.Request().Context() + rw := ctx.Response() + req := ctx.Request() + + var userID string + if h.config.Environment != "production" { + userID = h.config.TestUserID + } else { + userID = h.getAuthenticatedUser(ctx) + if userID == "" { + returnURL := req.URL.String() + return ctx.Redirect(http.StatusFound, "/login?return_url="+url.QueryEscape(returnURL)) + } + } + + ar, err := h.oauth2.NewAuthorizeRequest(c, req) + if err != nil { + h.oauth2.WriteAuthorizeError(c, rw, ar, err) + return nil + } + + session := repository.NewOAuthSession(userID) + for _, scope := range ar.GetRequestedScopes() { + ar.GrantScope(scope) + } + + response, err := h.oauth2.NewAuthorizeResponse(c, ar, session) + if err != nil { + h.oauth2.WriteAuthorizeError(c, rw, ar, err) + return nil + } + + h.oauth2.WriteAuthorizeResponse(c, rw, ar, response) + return nil +} + +func (h *Handler) Token(ctx echo.Context) error { + c := ctx.Request().Context() + rw := ctx.Response() + req := ctx.Request() + + session := repository.NewOAuthSession("") + accessRequest, err := h.oauth2.NewAccessRequest(c, req, session) + if err != nil { + h.oauth2.WriteAccessError(c, rw, accessRequest, err) + return nil + } + + for _, scope := range accessRequest.GetRequestedScopes() { + accessRequest.GrantScope(scope) + } + + response, err := h.oauth2.NewAccessResponse(c, accessRequest) + if err != nil { + h.oauth2.WriteAccessError(c, rw, accessRequest, err) + return nil + } + + h.oauth2.WriteAccessResponse(c, rw, accessRequest, response) + return nil +} + +func (h *Handler) GetUserInfo(ctx echo.Context) error { + token, err := h.extractBearerToken(ctx) + if err != nil { + return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidRequest}) + } + return h.handleUserInfo(ctx, token) +} + +func (h *Handler) PostUserInfo(ctx echo.Context) error { + // RFC 6750: POST can use Authorization header OR form body + token, err := h.extractBearerToken(ctx) + if err != nil { + // Try form body (application/x-www-form-urlencoded) + token = ctx.FormValue("access_token") + if token == "" { + return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidRequest}) + } + } + return h.handleUserInfo(ctx, token) +} + +func (h *Handler) extractBearerToken(ctx echo.Context) (string, error) { + authHeader := ctx.Request().Header.Get("Authorization") + if authHeader == "" { + return "", errors.New("no authorization header") + } + + // RFC 6750: The access token type is case-insensitive + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return authHeader[7:], nil // len("bearer ") == 7 + } + return "", errors.New("invalid authorization header") +} + +func (h *Handler) handleUserInfo(ctx echo.Context, token string) error { + c := ctx.Request().Context() + + _, ar, err := h.oauth2.IntrospectToken(c, token, fosite.AccessToken, repository.NewOAuthSession("")) + if err != nil { + return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidGrant}) + } + + return ctx.JSON(http.StatusOK, gen.UserInfo{Sub: ar.GetSession().GetSubject()}) +} + +func (h *Handler) GetJWKS(ctx echo.Context) error { + pubKey := &h.config.PrivateKey.PublicKey + + hash := sha256.Sum256(pubKey.N.Bytes()) + kid := base64.RawURLEncoding.EncodeToString(hash[:8]) + + jwk := jose.JSONWebKey{ + Key: pubKey, + KeyID: kid, + Algorithm: string(jose.RS256), + Use: "sig", + } + + return ctx.JSON(http.StatusOK, map[string]interface{}{ + "keys": []jose.JSONWebKey{jwk}, + }) +} + +func (h *Handler) GetOpenIDConfiguration(ctx echo.Context) error { + issuer := h.config.Issuer + scopesSupported := []string{"openid", "profile", "email"} + claimsSupported := []string{"sub", "name", "preferred_username", "email", "email_verified"} + codeChallengeMethodsSupported := []string{"S256", "plain"} + tokenEndpointAuthMethodsSupported := []string{"client_secret_basic", "client_secret_post"} + + return ctx.JSON(http.StatusOK, gen.OpenIDConfiguration{ + Issuer: issuer, + AuthorizationEndpoint: issuer + "/oauth2/authorize", + TokenEndpoint: issuer + "/oauth2/token", + UserinfoEndpoint: issuer + "/oauth2/userinfo", + JwksUri: issuer + "/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + SubjectTypesSupported: []string{"public"}, + IdTokenSigningAlgValuesSupported: []string{"RS256"}, + ScopesSupported: &scopesSupported, + ClaimsSupported: &claimsSupported, + CodeChallengeMethodsSupported: &codeChallengeMethodsSupported, + TokenEndpointAuthMethodsSupported: &tokenEndpointAuthMethodsSupported, + }) +} From aac184de3ae3ff3fba8128b5c30ec02b62b0b573 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:53:45 +0900 Subject: [PATCH 09/26] feat: integrate OAuth2/OIDC into server --- cmd/config.go | 3 +- cmd/serve.go | 39 ++- config.yaml | 4 + go.mod | 99 ++++-- go.sum | 848 ++++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 862 insertions(+), 131 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index e7d1efa..ad428e9 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -66,7 +66,8 @@ func loadConfig(configPath string) (*Config, error) { } if err := k.Load(env.Provider("OIDC_", ".", func(s string) string { - return strings.ToLower(strings.TrimPrefix(s, "OIDC_")) + key := strings.ToLower(strings.TrimPrefix(s, "OIDC_")) + return strings.ReplaceAll(key, "_", ".") }), nil); err != nil { return nil, err } diff --git a/cmd/serve.go b/cmd/serve.go index dd278de..127372f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -23,23 +23,44 @@ func newServer(cfg Config) (http.Handler, error) { return nil, err } - clientRepo := repository.NewClientRepository(queries) - clientUC := usecase.NewClientUseCase(clientRepo) - handler := v1.NewHandler(clientUC) + privateKey, err := loadOrGenerateKey(cfg.OAuth.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load/generate RSA key: %w", err) + } + + oauthStorage := repository.NewOAuthStorage(queries) + defaults := defaultOAuthProviderConfig() + oauth2Provider := newOAuthProvider(oauthStorage, OAuthProviderConfig{ + Issuer: cfg.Host, + AccessTokenLifespan: defaults.AccessTokenLifespan, + RefreshTokenLifespan: defaults.RefreshTokenLifespan, + AuthCodeLifespan: defaults.AuthCodeLifespan, + IDTokenLifespan: defaults.IDTokenLifespan, + Secret: []byte(cfg.OAuth.Secret), + }, privateKey) + + handler := v1.NewHandler( + usecase.NewClientUseCase(repository.NewClientRepository(queries)), + oauth2Provider, + v1.OAuthConfig{ + Issuer: cfg.Host, + SessionSecret: []byte(cfg.OAuth.Secret), + PrivateKey: privateKey, + Environment: cfg.Environment, + TestUserID: cfg.OAuth.TestUserID, + }, + ) e := echo.New() - e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{"*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, })) - gen.RegisterHandlers(e, handler) - - e.GET("/health", func(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) - }) + e.GET("/login", handler.GetLogin) + e.POST("/login", handler.PostLogin) + e.GET("/logout", handler.Logout) return e, nil } diff --git a/config.yaml b/config.yaml index 9a3da1a..22c163b 100644 --- a/config.yaml +++ b/config.yaml @@ -7,3 +7,7 @@ database: user: root password: password name: oidc + +oauth: + test_mode: true + test_user_id: testuser diff --git a/go.mod b/go.mod index 0e4654e..98b5551 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,10 @@ go 1.25.5 require ( github.com/alecthomas/kong v1.13.0 + github.com/go-jose/go-jose/v4 v4.1.3 github.com/go-sql-driver/mysql v1.9.3 - github.com/google/uuid v1.5.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/sessions v1.4.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/confmap v1.0.0 github.com/knadh/koanf/providers/env v1.1.0 @@ -13,54 +15,95 @@ require ( github.com/knadh/koanf/v2 v2.3.0 github.com/labstack/echo/v4 v4.15.0 github.com/oapi-codegen/runtime v1.1.2 - golang.org/x/crypto v0.46.0 + github.com/ory/fosite v0.49.0 + golang.org/x/crypto v0.47.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cristalhq/jwt/v4 v4.0.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/ristretto v1.0.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/gobuffalo/pop/v6 v6.1.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/labstack/gommon v0.4.2 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/goveralls v0.0.12 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/onsi/gomega v1.34.1 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/speakeasy-api/jsonpath v0.6.0 // indirect - github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/openzipkin/zipkin-go v0.4.2 // indirect + github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect + github.com/ory/go-convenience v0.1.0 // indirect + github.com/ory/x v0.0.665 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/cobra v1.7.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.16.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.4.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect - github.com/woodsbury/decimal128 v1.3.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect + go.opentelemetry.io/otel v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect + go.opentelemetry.io/otel/metric v1.21.0 // indirect + go.opentelemetry.io/otel/sdk v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.21.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.14.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.40.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect + google.golang.org/grpc v1.59.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 41b0634..ace8efe 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,46 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= @@ -9,61 +50,243 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/cristalhq/jwt/v4 v4.0.2 h1:g/AD3h0VicDamtlM70GWGElp8kssQEv+5wYd7L9WOhU= +github.com/cristalhq/jwt/v4 v4.0.2/go.mod h1:HnYraSNKDRag1DZP92rYHyrjyQHnVEHPNqesmzs+miQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= -github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/dgraph-io/ristretto v1.0.0 h1:SYG07bONKMlFDUYu5pEu3DGAh8c2OFNzKm6G9J4Si84= +github.com/dgraph-io/ristretto v1.0.0/go.mod h1:jTi2FiYEhQ1NsMmA7DeBykizjOuY88NhKBkepyu1jPc= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobuffalo/attrs v1.0.3/go.mod h1:KvDJCE0avbufqS0Bw3UV7RQynESY0jjod+572ctX4t8= +github.com/gobuffalo/envy v1.10.2/go.mod h1:qGAGwdvDsaEtPhfBzb3o0SfDea8ByGn9j8bKmVft9z8= +github.com/gobuffalo/fizz v1.14.4/go.mod h1:9/2fGNXNeIFOXEEgTPJwiK63e44RjG+Nc4hfMm1ArGM= +github.com/gobuffalo/flect v0.3.0/go.mod h1:5pf3aGnsvqvCj50AVni7mJJF8ICxGZ8HomberC3pXLE= +github.com/gobuffalo/flect v1.0.0/go.mod h1:l9V6xSb4BlXwsxEMj3FVEub2nkdQjWhPvD8XTTlHPQc= +github.com/gobuffalo/genny/v2 v2.1.0/go.mod h1:4yoTNk4bYuP3BMM6uQKYPvtP6WsXFGm2w2EFYZdRls8= +github.com/gobuffalo/github_flavored_markdown v1.1.3/go.mod h1:IzgO5xS6hqkDmUh91BW/+Qxo/qYnvfzoz3A7uLkg77I= +github.com/gobuffalo/helpers v0.6.7/go.mod h1:j0u1iC1VqlCaJEEVkZN8Ia3TEzfj/zoXANqyJExTMTA= +github.com/gobuffalo/logger v1.0.7/go.mod h1:u40u6Bq3VVvaMcy5sRBclD8SXhBYPS0Qk95ubt+1xJM= +github.com/gobuffalo/nulls v0.4.2/go.mod h1:EElw2zmBYafU2R9W4Ii1ByIj177wA/pc0JdjtD0EsH8= +github.com/gobuffalo/packd v1.0.2/go.mod h1:sUc61tDqGMXON80zpKGp92lDb86Km28jfvX7IAyxFT8= +github.com/gobuffalo/plush/v4 v4.1.16/go.mod h1:6t7swVsarJ8qSLw1qyAH/KbrcSTwdun2ASEQkOznakg= +github.com/gobuffalo/plush/v4 v4.1.18/go.mod h1:xi2tJIhFI4UdzIL8sxZtzGYOd2xbBpcFbLZlIPGGZhU= +github.com/gobuffalo/pop/v6 v6.1.1 h1:eUDBaZcb0gYrmFnKwpuTEUA7t5ZHqNfvS4POqJYXDZY= +github.com/gobuffalo/pop/v6 v6.1.1/go.mod h1:1n7jAmI1i7fxuXPZjZb0VBPQDbksRtCoFnrDV5IsvaI= +github.com/gobuffalo/tags/v3 v3.1.4/go.mod h1:ArRNo3ErlHO8BtdA0REaZxijuWnWzF6PUXngmMXd2I0= +github.com/gobuffalo/validate/v3 v3.3.3/go.mod h1:YC7FsbJ/9hW/VjQdmXPvFqvRis4vrRYFxr69WiNZw6g= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI= +github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM= +github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs= +github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jandelgado/gcov2lcov v1.0.5 h1:rkBt40h0CVK4oCb8Dps950gvfd1rYvQ8+cWa346lVU0= +github.com/jandelgado/gcov2lcov v1.0.5/go.mod h1:NnSxK6TMlg1oGDBfGelGbjgorT5/L3cchlbtgFYZSss= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= +github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= @@ -72,12 +295,18 @@ github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWy github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME= +github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c= github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -85,158 +314,591 @@ github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/luna-duclos/instrumentedsql v1.1.3/go.mod h1:9J1njvFds+zN7y85EDhN9XNQLANWwZt2ULeIC8yMNYs= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg= +github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ= +github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/nyaruka/phonenumbers v1.1.6 h1:DcueYq7QrOArAprAYNoQfDgp0KetO4LqtnBtQC6Wyes= +github.com/nyaruka/phonenumbers v1.1.6/go.mod h1:yShPJHDSH3aTKzCbXyVxNpbl2kA+F+Ne5Pun/MvFRos= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/oleiade/reflections v1.0.1 h1:D1XO3LVEYroYskEsoSiGItp9RUxG6jWnCVvrqH0HHQM= +github.com/oleiade/reflections v1.0.1/go.mod h1:rdFxbxq4QXVZWj0F+e9jqjDkc7dbp97vkRixKo2JR60= +github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA= +github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= +github.com/ory/fosite v0.49.0 h1:KNqO7RVt/1X8F08/UI0Y+GRvcpscCWgjqvpLBQPRovo= +github.com/ory/fosite v0.49.0/go.mod h1:FAn7IY+I6DjT1r29wMouPeRYq63DWUuBj++96uOS4mE= +github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc= +github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI= +github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8= +github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs= +github.com/ory/herodot v0.10.2 h1:gGvNMHgAwWzdP/eo+roSiT5CGssygHSjDU7MSQNlJ4E= +github.com/ory/herodot v0.10.2/go.mod h1:MMNmY6MG1uB6fnXYFaHoqdV23DTWctlPsmRCeq/2+wc= +github.com/ory/jsonschema/v3 v3.0.8 h1:Ssdb3eJ4lDZ/+XnGkvQS/te0p+EkolqwTsDOCxr/FmU= +github.com/ory/jsonschema/v3 v3.0.8/go.mod h1:ZPzqjDkwd3QTnb2Z6PAS+OTvBE2x5i6m25wCGx54W/0= +github.com/ory/x v0.0.665 h1:61vv0ObCDSX1vOQYbxBeqDiv4YiPmMT91lYxDaaKX08= +github.com/ory/x v0.0.665/go.mod h1:7SCTki3N0De3ZpqlxhxU/94ZrOCfNEnXwVtd0xVt+L8= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= -github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= -github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME= +github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw= +github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= -github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= -github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= -github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= +go.opentelemetry.io/contrib/propagators/b3 v1.21.0 h1:uGdgDPNzwQWRwCXJgw/7h29JaRqcq9B87Iv4hJDKAZw= +go.opentelemetry.io/contrib/propagators/b3 v1.21.0/go.mod h1:D9GQXvVGT2pzyTfp1QBOnD1rzKEWzKjjwu5q2mslCUI= +go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 h1:f4beMGDKiVzg9IcX7/VuWVy+oGdjx3dNJ72YehmtY5k= +go.opentelemetry.io/contrib/propagators/jaeger v1.21.1/go.mod h1:U9jhkEl8d1LL+QXY7q3kneJWJugiN3kZJV2OWz3hkBY= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 h1:Qb+5A+JbIjXwO7l4HkRUhgIn4Bzz0GNS2q+qdmSx+0c= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1/go.mod h1:G4vNCm7fRk0kjZ6pGNLo5SpLxAUvOfSrcaegnT8TPck= +go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= +go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0/go.mod h1:zgBdWWAu7oEEMC06MMKc5NLbA/1YDXV1sMpSqEeLQLg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= +go.opentelemetry.io/otel/exporters/zipkin v1.21.0/go.mod h1:83oMKR6DzmHisFOW3I+yIMGZUTjxiWaiBI8M8+TU5zE= +go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0= +golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:J7XzRzVy1+IPwWHZUzoD0IccYZIrXILAQpc+Qy9CMhY= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 h1:JpwMPBpFN3uKhdaekDpiNlImDdkUAyiJ6ez/uxGaUSo= +google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:0xJLfVdJqpAPl8tDg1ujOCGzx6LFLttXT5NhllGOXY4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= From b00091212536602259efaadb1cb7fc3298528bcf Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 01:03:22 +0900 Subject: [PATCH 10/26] fix: restore Recover middleware and health endpoint --- cmd/serve.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 127372f..0b235e9 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,6 +52,7 @@ func newServer(cfg Config) (http.Handler, error) { ) e := echo.New() + e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{"*"}, @@ -61,6 +62,9 @@ func newServer(cfg Config) (http.Handler, error) { e.GET("/login", handler.GetLogin) e.POST("/login", handler.PostLogin) e.GET("/logout", handler.Logout) + e.GET("/health", func(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) + }) return e, nil } From 7e6a48d0c83c8c4e905cdbe68af986e1ef7c0ca2 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:37:45 +0900 Subject: [PATCH 11/26] chore: remove unused test_mode from config --- config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/config.yaml b/config.yaml index 22c163b..3b29137 100644 --- a/config.yaml +++ b/config.yaml @@ -9,5 +9,4 @@ database: name: oidc oauth: - test_mode: true test_user_id: testuser From dd0d6c59eeb0a228ffd5ea101bb521652dea6d7d Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 02:59:43 +0900 Subject: [PATCH 12/26] feat: add Portal authentication integration --- cmd/config.go | 30 +- cmd/serve.go | 38 ++- config.yaml | 8 + internal/repository/user.go | 134 ++++++++ internal/router/v1/auth.go | 46 ++- internal/router/v1/client_test.go | 36 +- internal/router/v1/handler.go | 4 + internal/router/v1/handler_test.go | 510 ----------------------------- 8 files changed, 276 insertions(+), 530 deletions(-) create mode 100644 internal/repository/user.go delete mode 100644 internal/router/v1/handler_test.go diff --git a/cmd/config.go b/cmd/config.go index ad428e9..1669e6b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -15,9 +15,14 @@ type Config struct { Host string `koanf:"host"` Environment string `koanf:"environment"` Database DatabaseConfig `koanf:"database"` + Portal PortalConfig `koanf:"portal"` OAuth OAuthConfig `koanf:"oauth"` } +type PortalConfig struct { + Database DatabaseConfig `koanf:"database"` +} + type DatabaseConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` @@ -33,16 +38,21 @@ type OAuthConfig struct { } var defaults = map[string]any{ - "host": "http://localhost:8080", - "environment": "development", - "database.host": "localhost", - "database.port": 3307, - "database.user": "root", - "database.password": "password", - "database.name": "oidc", - "oauth.secret": "my-super-secret-signing-key-32!!", - "oauth.key_file": "data/private.pem", - "oauth.test_user_id": "testuser", + "host": "http://localhost:8080", + "environment": "development", + "database.host": "localhost", + "database.port": 3307, + "database.user": "root", + "database.password": "password", + "database.name": "oidc", + "portal.database.host": "localhost", + "portal.database.port": 3306, + "portal.database.user": "root", + "portal.database.password": "password", + "portal.database.name": "portal", + "oauth.secret": "my-super-secret-signing-key-32!!", + "oauth.key_file": "data/private.pem", + "oauth.test_user_id": "testuser", } func loadConfig(configPath string) (*Config, error) { diff --git a/cmd/serve.go b/cmd/serve.go index 0b235e9..733c2e5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -12,13 +12,19 @@ import ( "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/repository/oidc" + "github.com/traPtitech/portal-oidc/internal/repository/portal" v1 "github.com/traPtitech/portal-oidc/internal/router/v1" "github.com/traPtitech/portal-oidc/internal/router/v1/gen" "github.com/traPtitech/portal-oidc/internal/usecase" ) func newServer(cfg Config) (http.Handler, error) { - queries, err := setupDatabase(cfg.Database) + queries, err := setupOIDCDatabase(cfg.Database) + if err != nil { + return nil, err + } + + portalQueries, err := setupPortalDatabase(cfg.Portal.Database) if err != nil { return nil, err } @@ -42,6 +48,7 @@ func newServer(cfg Config) (http.Handler, error) { handler := v1.NewHandler( usecase.NewClientUseCase(repository.NewClientRepository(queries)), oauth2Provider, + repository.NewUserRepository(portalQueries), v1.OAuthConfig{ Issuer: cfg.Host, SessionSecret: []byte(cfg.OAuth.Secret), @@ -69,22 +76,43 @@ func newServer(cfg Config) (http.Handler, error) { return e, nil } -func setupDatabase(cfg DatabaseConfig) (*oidc.Queries, error) { +func setupOIDCDatabase(cfg DatabaseConfig) (*oidc.Queries, error) { dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name) db, err := sql.Open("mysql", dsn) if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) + return nil, fmt.Errorf("failed to open oidc database: %w", err) } if err := db.PingContext(context.Background()); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) + return nil, fmt.Errorf("failed to ping oidc database: %w", err) } queries, err := oidc.Prepare(context.Background(), db) if err != nil { - return nil, fmt.Errorf("failed to prepare queries: %w", err) + return nil, fmt.Errorf("failed to prepare oidc queries: %w", err) + } + + return queries, nil +} + +func setupPortalDatabase(cfg DatabaseConfig) (*portal.Queries, error) { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", + cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name) + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open portal database: %w", err) + } + + if err := db.PingContext(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping portal database: %w", err) + } + + queries, err := portal.Prepare(context.Background(), db) + if err != nil { + return nil, fmt.Errorf("failed to prepare portal queries: %w", err) } return queries, nil diff --git a/config.yaml b/config.yaml index 3b29137..c304362 100644 --- a/config.yaml +++ b/config.yaml @@ -8,5 +8,13 @@ database: password: password name: oidc +portal: + database: + host: localhost + port: 3306 + user: root + password: password + name: portal + oauth: test_user_id: testuser diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..459407d --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,134 @@ +package repository + +import ( + "context" + "crypto/sha512" + "crypto/subtle" + "database/sql" + "encoding/base64" + "errors" + "strings" + + "golang.org/x/crypto/pbkdf2" + + "github.com/traPtitech/portal-oidc/internal/repository/portal" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrInvalidPassword = errors.New("invalid password") + ErrUserNotActive = errors.New("user is not active") +) + +type User struct { + ID string + TrapID string +} + +type UserRepository interface { + Authenticate(ctx context.Context, trapID, password string) (*User, error) + GetByID(ctx context.Context, id string) (*User, error) +} + +type userRepository struct { + queries *portal.Queries +} + +func NewUserRepository(queries *portal.Queries) UserRepository { + return &userRepository{queries: queries} +} + +func (r *userRepository) Authenticate(ctx context.Context, trapID, password string) (*User, error) { + user, err := r.queries.GetUserByTrapID(ctx, trapID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + if !verifyPBKDF2Password(password, user.PasswordHash) { + return nil, ErrInvalidPassword + } + + statuses, err := r.queries.ListUserStatuses(ctx, user.ID) + if err != nil { + return nil, err + } + + isActive := false + for _, status := range statuses { + if status.Status == "active" { + isActive = true + break + } + } + + if !isActive && len(statuses) > 0 { + return nil, ErrUserNotActive + } + + return &User{ + ID: user.ID, + TrapID: user.TrapID, + }, nil +} + +func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) { + user, err := r.queries.GetUserByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &User{ + ID: user.ID, + TrapID: user.TrapID, + }, nil +} + +// verifyPBKDF2Password verifies a password against a PBKDF2-SHA512 hash. +// Expected format: "pbkdf2_sha512$iterations$salt$hash" (base64 encoded) +func verifyPBKDF2Password(password, storedHash string) bool { + parts := strings.Split(storedHash, "$") + if len(parts) != 4 { + return false + } + + algorithm := parts[0] + if algorithm != "pbkdf2_sha512" { + return false + } + + iterations, err := parseIterations(parts[1]) + if err != nil { + return false + } + + salt, err := base64.StdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + + expectedHash, err := base64.StdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + + computedHash := pbkdf2.Key([]byte(password), salt, iterations, len(expectedHash), sha512.New) + + return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 +} + +func parseIterations(s string) (int, error) { + var n int + for _, c := range s { + if c < '0' || c > '9' { + return 0, errors.New("invalid iterations") + } + n = n*10 + int(c-'0') + } + return n, nil +} diff --git a/internal/router/v1/auth.go b/internal/router/v1/auth.go index 7f750df..5574c68 100644 --- a/internal/router/v1/auth.go +++ b/internal/router/v1/auth.go @@ -1,10 +1,13 @@ package v1 import ( + "errors" "html" "net/http" "github.com/labstack/echo/v4" + + "github.com/traPtitech/portal-oidc/internal/repository" ) const sessionName = "oidc_session" @@ -15,6 +18,11 @@ func (h *Handler) GetLogin(ctx echo.Context) error { returnURL = "/" } + devNote := "" + if h.config.Environment != "production" { + devNote = `

Test user: testuser / password

` + } + page := ` @@ -31,11 +39,11 @@ func (h *Handler) GetLogin(ctx echo.Context) error {

Login

- +
-

Test user: testuser / password

+ ` + devNote + ` ` @@ -47,7 +55,16 @@ func (h *Handler) PostLogin(ctx echo.Context) error { password := ctx.FormValue("password") returnURL := ctx.FormValue("return_url") - if username != "testuser" || password != "password" { + var userID string + var err error + + if h.config.Environment != "production" { + userID, err = h.authenticateTestUser(username, password) + } else { + userID, err = h.authenticatePortalUser(ctx, username, password) + } + + if err != nil { return ctx.HTML(http.StatusUnauthorized, ` Login Failed @@ -64,7 +81,7 @@ func (h *Handler) PostLogin(ctx echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get session") } - session.Values["user_id"] = username + session.Values["user_id"] = userID session.Values["authenticated"] = true if err := session.Save(ctx.Request(), ctx.Response()); err != nil { @@ -77,6 +94,27 @@ func (h *Handler) PostLogin(ctx echo.Context) error { return ctx.Redirect(http.StatusFound, returnURL) } +func (h *Handler) authenticateTestUser(username, password string) (string, error) { + if username == "testuser" && password == "password" { + return h.config.TestUserID, nil + } + return "", errors.New("invalid credentials") +} + +func (h *Handler) authenticatePortalUser(ctx echo.Context, trapID, password string) (string, error) { + user, err := h.userRepo.Authenticate(ctx.Request().Context(), trapID, password) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) || + errors.Is(err, repository.ErrInvalidPassword) || + errors.Is(err, repository.ErrUserNotActive) { + return "", errors.New("authentication failed") + } + return "", err + } + + return user.ID, nil +} + func (h *Handler) Logout(ctx echo.Context) error { session, err := h.sessions.Get(ctx.Request(), sessionName) if err != nil { diff --git a/internal/router/v1/client_test.go b/internal/router/v1/client_test.go index bd7975f..b244c23 100644 --- a/internal/router/v1/client_test.go +++ b/internal/router/v1/client_test.go @@ -11,12 +11,15 @@ import ( "path/filepath" "strings" "testing" + "time" _ "github.com/go-sql-driver/mysql" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/v2" "github.com/labstack/echo/v4" + "github.com/ory/fosite" + "github.com/ory/fosite/compose" "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/repository/oidc" @@ -119,7 +122,38 @@ func setupTestHandler(t *testing.T) (*Handler, func()) { clientRepo := repository.NewClientRepository(queries) clientUseCase := usecase.NewClientUseCase(clientRepo) - handler := NewHandler(clientUseCase) + oauthStorage := repository.NewOAuthStorage(queries) + fositeConfig := &fosite.Config{ + AccessTokenLifespan: time.Hour, + RefreshTokenLifespan: 30 * 24 * time.Hour, + AuthorizeCodeLifespan: 5 * time.Minute, + IDTokenLifespan: time.Hour, + GlobalSecret: []byte("test-secret-key-32-characters!!"), + ScopeStrategy: fosite.ExactScopeStrategy, + AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, + SendDebugMessagesToClients: false, + EnforcePKCE: true, + EnforcePKCEForPublicClients: true, + EnablePKCEPlainChallengeMethod: true, + AccessTokenIssuer: "http://localhost:8080", + IDTokenIssuer: "http://localhost:8080", + } + oauth2Provider := compose.Compose( + fositeConfig, + oauthStorage, + compose.NewOAuth2HMACStrategy(fositeConfig), + compose.OAuth2AuthorizeExplicitFactory, + compose.OAuth2PKCEFactory, + compose.OAuth2RefreshTokenGrantFactory, + compose.OAuth2TokenIntrospectionFactory, + compose.OAuth2TokenRevocationFactory, + ) + + handler := NewHandler(clientUseCase, oauth2Provider, nil, OAuthConfig{ + Issuer: "http://localhost:8080", + Environment: "development", + TestUserID: "testuser", + }) cleanup := func() { _ = queries.DeleteAllClients(ctx) diff --git a/internal/router/v1/handler.go b/internal/router/v1/handler.go index da22374..8c692e6 100644 --- a/internal/router/v1/handler.go +++ b/internal/router/v1/handler.go @@ -7,12 +7,14 @@ import ( "github.com/gorilla/sessions" "github.com/ory/fosite" + "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/usecase" ) type Handler struct { clientUseCase usecase.ClientUseCase oauth2 fosite.OAuth2Provider + userRepo repository.UserRepository sessions *sessions.CookieStore config OAuthConfig } @@ -28,6 +30,7 @@ type OAuthConfig struct { func NewHandler( clientUseCase usecase.ClientUseCase, oauth2 fosite.OAuth2Provider, + userRepo repository.UserRepository, config OAuthConfig, ) *Handler { store := sessions.NewCookieStore(config.SessionSecret) @@ -41,6 +44,7 @@ func NewHandler( return &Handler{ clientUseCase: clientUseCase, oauth2: oauth2, + userRepo: userRepo, sessions: store, config: config, } diff --git a/internal/router/v1/handler_test.go b/internal/router/v1/handler_test.go deleted file mode 100644 index 2e05fec..0000000 --- a/internal/router/v1/handler_test.go +++ /dev/null @@ -1,510 +0,0 @@ -package v1 - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - _ "github.com/go-sql-driver/mysql" - "github.com/knadh/koanf/providers/confmap" - "github.com/knadh/koanf/providers/env" - "github.com/knadh/koanf/v2" - "github.com/labstack/echo/v4" - "github.com/ory/fosite" - "github.com/ory/fosite/compose" - - "github.com/traPtitech/portal-oidc/internal/repository" - "github.com/traPtitech/portal-oidc/internal/repository/oidc" - "github.com/traPtitech/portal-oidc/internal/router/v1/gen" - "github.com/traPtitech/portal-oidc/internal/testutil" - "github.com/traPtitech/portal-oidc/internal/usecase" -) - -const ( - testDBName = "oidc_test" -) - -var testDB *sql.DB - -func TestMain(m *testing.M) { - k := koanf.New(".") - ctx := context.Background() - - // デフォルト値 - _ = k.Load(confmap.Provider(map[string]any{ - "mariadb.username": "root", - "mariadb.password": "password", - "mariadb.hostname": "127.0.0.1", - "mariadb.port": "3307", - }, "."), nil) - - // 環境変数で上書き (MARIADB_USERNAME など) - _ = k.Load(env.Provider("MARIADB_", ".", func(s string) string { - return strings.ToLower(strings.TrimPrefix(s, "MARIADB_")) - }), nil) - - user := k.String("mariadb.username") - pass := k.String("mariadb.password") - host := k.String("mariadb.hostname") - port := k.String("mariadb.port") - - // Connect without database to create test database - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?parseTime=true", user, pass, host, port) - db, err := sql.Open("mysql", dsn) - if err != nil { - fmt.Printf("failed to connect to database: %v\n", err) - os.Exit(1) - } - - if err := db.PingContext(ctx); err != nil { - fmt.Printf("failed to ping database: %v\n", err) - os.Exit(1) - } - - // Create test database - _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", testDBName)) - if err != nil { - fmt.Printf("failed to create test database: %v\n", err) - os.Exit(1) - } - _ = db.Close() - - // Connect to test database - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&multiStatements=true", user, pass, host, port, testDBName) - testDB, err = sql.Open("mysql", dsn) - if err != nil { - fmt.Printf("failed to connect to test database: %v\n", err) - os.Exit(1) - } - - // Load and execute schema - root, err := testutil.FindProjectRoot() - if err != nil { - fmt.Printf("failed to find project root: %v\n", err) - os.Exit(1) - } - schemaPath := filepath.Join(root, "db", "schema.sql") - schemaSQL, err := os.ReadFile(schemaPath) //nolint:gosec - if err != nil { - fmt.Printf("failed to read schema file: %v\n", err) - os.Exit(1) - } - - _, err = testDB.ExecContext(ctx, string(schemaSQL)) - if err != nil { - fmt.Printf("failed to create schema: %v\n", err) - os.Exit(1) - } - - // Run tests - code := m.Run() - - // Cleanup - _ = testDB.Close() - - os.Exit(code) -} - -func setupTestHandler(t *testing.T) (*Handler, func()) { - t.Helper() - - ctx := context.Background() - - queries, err := oidc.Prepare(ctx, testDB) - if err != nil { - t.Fatalf("failed to prepare queries: %v", err) - } - - // Clean up tables before test - if err := queries.DeleteAllClients(ctx); err != nil { - t.Fatalf("failed to clean up clients table: %v", err) - } - if err := queries.DeleteAllAuthorizationCodes(ctx); err != nil { - t.Fatalf("failed to clean up authorization_codes table: %v", err) - } - if err := queries.DeleteAllTokens(ctx); err != nil { - t.Fatalf("failed to clean up tokens table: %v", err) - } - - clientRepo := repository.NewClientRepository(queries) - clientUseCase := usecase.NewClientUseCase(clientRepo) - - // Create OAuth2 provider for testing - oauthStorage := repository.NewOAuthStorage(queries) - fositeConfig := &fosite.Config{ - AccessTokenLifespan: time.Hour, - RefreshTokenLifespan: 30 * 24 * time.Hour, - AuthorizeCodeLifespan: 5 * time.Minute, - IDTokenLifespan: time.Hour, - GlobalSecret: []byte("test-secret-key-32-characters!!"), - ScopeStrategy: fosite.ExactScopeStrategy, - AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, - SendDebugMessagesToClients: false, - EnforcePKCE: true, - EnforcePKCEForPublicClients: true, - EnablePKCEPlainChallengeMethod: true, - AccessTokenIssuer: "http://localhost:8080", - IDTokenIssuer: "http://localhost:8080", - } - oauth2Provider := compose.Compose( - fositeConfig, - oauthStorage, - compose.NewOAuth2HMACStrategy(fositeConfig), - compose.OAuth2AuthorizeExplicitFactory, - compose.OAuth2PKCEFactory, - compose.OAuth2RefreshTokenGrantFactory, - compose.OAuth2TokenIntrospectionFactory, - compose.OAuth2TokenRevocationFactory, - ) - - handler := NewHandler(clientUseCase, oauth2Provider, OAuthConfig{ - Issuer: "http://localhost:8080", - }) - - cleanup := func() { - _ = queries.DeleteAllTokens(ctx) - _ = queries.DeleteAllAuthorizationCodes(ctx) - _ = queries.DeleteAllClients(ctx) - _ = queries.Close() - } - - return handler, cleanup -} - -func TestIntegration_CreateClient(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - reqBody := `{"name":"integration-test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusCreated, rec.Body.String()) - } - - var resp gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if resp.Name != "integration-test-client" { - t.Errorf("Name = %q, want %q", resp.Name, "integration-test-client") - } - if resp.ClientType != gen.Confidential { - t.Errorf("ClientType = %q, want %q", resp.ClientType, gen.Confidential) - } - if resp.ClientSecret == "" { - t.Error("ClientSecret should not be empty") - } - if len(resp.RedirectUris) != 1 { - t.Errorf("len(RedirectUris) = %d, want 1", len(resp.RedirectUris)) - } -} - -func TestIntegration_GetClients(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // Create a client first - reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - // Get clients list - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var clients []gen.Client - if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if len(clients) != 1 { - t.Errorf("len(clients) = %d, want 1", len(clients)) - } -} - -func TestIntegration_GetClient(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // Create a client - reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - var created gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - // Get client by ID - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var client gen.Client - if err := json.Unmarshal(rec.Body.Bytes(), &client); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if client.ClientId != created.ClientId { - t.Errorf("ClientId = %s, want %s", client.ClientId, created.ClientId) - } - if client.Name != "test-client" { - t.Errorf("Name = %q, want %q", client.Name, "test-client") - } -} - -func TestIntegration_GetClient_NotFound(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/00000000-0000-0000-0000-000000000000", nil) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) - } -} - -func TestIntegration_UpdateClient(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // Create a client - reqBody := `{"name":"original","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - var created gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - // Update client - updateBody := `{"name":"updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` - req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d, body = %s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var updated gen.Client - if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if updated.Name != "updated" { - t.Errorf("Name = %q, want %q", updated.Name, "updated") - } - if updated.ClientType != gen.Public { - t.Errorf("ClientType = %q, want %q", updated.ClientType, gen.Public) - } - if updated.RedirectUris[0] != "http://localhost:4000/callback" { - t.Errorf("RedirectUris[0] = %q, want %q", updated.RedirectUris[0], "http://localhost:4000/callback") - } -} - -func TestIntegration_DeleteClient(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // Create a client - reqBody := `{"name":"to-delete","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - var created gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - // Delete client - req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent) - } - - // Verify deletion - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients/"+created.ClientId.String(), nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) - } -} - -func TestIntegration_RegenerateClientSecret(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // Create a client - reqBody := `{"name":"test-client","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(reqBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - var created gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - // Regenerate secret - req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var secret gen.ClientSecret - if err := json.Unmarshal(rec.Body.Bytes(), &secret); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - if secret.ClientSecret == "" { - t.Error("ClientSecret should not be empty") - } - if secret.ClientSecret == created.ClientSecret { - t.Error("new secret should be different from original") - } -} - -func TestIntegration_FullWorkflow(t *testing.T) { - handler, cleanup := setupTestHandler(t) - defer cleanup() - - e := echo.New() - gen.RegisterHandlers(e, handler) - - // 1. Create client - createBody := `{"name":"workflow-test","client_type":"confidential","redirect_uris":["http://localhost:3000/callback"]}` - req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients", strings.NewReader(createBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("Create: status = %d, want %d", rec.Code, http.StatusCreated) - } - - var created gen.ClientWithSecret - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - - // 2. Verify in list - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - var clients []gen.Client - if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if len(clients) != 1 { - t.Errorf("List: len = %d, want 1", len(clients)) - } - - // 3. Update client - updateBody := `{"name":"workflow-updated","client_type":"public","redirect_uris":["http://localhost:4000/callback"]}` - req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/clients/"+created.ClientId.String(), strings.NewReader(updateBody)) - req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("Update: status = %d, want %d", rec.Code, http.StatusOK) - } - - // 4. Regenerate secret - req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/clients/"+created.ClientId.String()+"/secret", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("RegenerateSecret: status = %d, want %d", rec.Code, http.StatusOK) - } - - // 5. Delete client - req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/clients/"+created.ClientId.String(), nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Errorf("Delete: status = %d, want %d", rec.Code, http.StatusNoContent) - } - - // 6. Verify list is empty - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients", nil) - rec = httptest.NewRecorder() - e.ServeHTTP(rec, req) - - if err := json.Unmarshal(rec.Body.Bytes(), &clients); err != nil { - t.Fatalf("failed to unmarshal response: %v", err) - } - if len(clients) != 0 { - t.Errorf("Final List: len = %d, want 0", len(clients)) - } -} From f3fffb16b3abeab60356c1a7e66a07a1b28e6513 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 26 Jan 2026 03:21:27 +0900 Subject: [PATCH 13/26] chore: add Docker environment variables for database hosts --- compose.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/compose.yaml b/compose.yaml index 220bc52..bcb48b8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,9 @@ services: environment: - CONFIG_FILE=/app/config.yaml - TZ=Asia/Tokyo + - OIDC_DATABASE_HOST=oidc + - OIDC_DATABASE_PORT=3306 + - OIDC_PORTAL_DATABASE_HOST=portal depends_on: portal: condition: service_healthy From f187389dc662c7eee63e8c1fb613035f8e146e77 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Mon, 16 Feb 2026 03:16:46 +0900 Subject: [PATCH 14/26] refactor(oauth): extract OAuth storage into internal/repository/oauth subpackage Split monolithic oauth_storage.go (445 lines) into focused files: - storage.go: Storage struct, constructor, client management - authcode.go: authorize code + PKCE sessions - token.go: access/refresh token CRUD + revocation - oidc.go: OpenID Connect in-memory sessions - client.go: fosite.Client implementation - session.go: fosite.Session/openid.Session implementation Key improvements in the extracted code: - Unexport Session fields (subject, username, expiresAt, extra) - Add interface compliance assertions (var _ fosite.Storage, var _ fosite.Client) - Use maps.Copy() instead of manual map copy loops - Set AuthTime in Session constructor for OIDC conformance - Enforce PKCE (EnforcePKCE: true, EnforcePKCEForPublicClients: true) Assisted-by: Claude Code (model: claude-opus-4-6) --- cmd/oauth.go | 8 +- cmd/serve.go | 3 +- internal/repository/oauth/authcode.go | 120 +++++++ internal/repository/oauth/client.go | 36 +++ internal/repository/oauth/oidc.go | 31 ++ internal/repository/oauth/session.go | 76 +++++ internal/repository/oauth/storage.go | 62 ++++ internal/repository/oauth/token.go | 153 +++++++++ internal/repository/oauth_storage.go | 444 -------------------------- internal/router/v1/client_test.go | 3 +- internal/router/v1/oauth.go | 13 +- 11 files changed, 494 insertions(+), 455 deletions(-) create mode 100644 internal/repository/oauth/authcode.go create mode 100644 internal/repository/oauth/client.go create mode 100644 internal/repository/oauth/oidc.go create mode 100644 internal/repository/oauth/session.go create mode 100644 internal/repository/oauth/storage.go create mode 100644 internal/repository/oauth/token.go delete mode 100644 internal/repository/oauth_storage.go diff --git a/cmd/oauth.go b/cmd/oauth.go index e25bee7..681d8ff 100644 --- a/cmd/oauth.go +++ b/cmd/oauth.go @@ -16,7 +16,7 @@ import ( "github.com/ory/fosite/compose" "github.com/ory/fosite/token/jwt" - "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oauth" ) type OAuthProviderConfig struct { @@ -39,7 +39,7 @@ func defaultOAuthProviderConfig() OAuthProviderConfig { } } -func newOAuthProvider(storage *repository.OAuthStorage, config OAuthProviderConfig, privateKey *rsa.PrivateKey) fosite.OAuth2Provider { +func newOAuthProvider(storage *oauth.Storage, config OAuthProviderConfig, privateKey *rsa.PrivateKey) fosite.OAuth2Provider { fositeConfig := &fosite.Config{ AccessTokenLifespan: config.AccessTokenLifespan, RefreshTokenLifespan: config.RefreshTokenLifespan, @@ -49,8 +49,8 @@ func newOAuthProvider(storage *repository.OAuthStorage, config OAuthProviderConf ScopeStrategy: fosite.ExactScopeStrategy, AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, SendDebugMessagesToClients: false, - EnforcePKCE: false, - EnforcePKCEForPublicClients: false, + EnforcePKCE: true, + EnforcePKCEForPublicClients: true, EnablePKCEPlainChallengeMethod: false, AccessTokenIssuer: config.Issuer, IDTokenIssuer: config.Issuer, diff --git a/cmd/serve.go b/cmd/serve.go index 733c2e5..8738d4b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -11,6 +11,7 @@ import ( "github.com/labstack/echo/v4/middleware" "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oauth" "github.com/traPtitech/portal-oidc/internal/repository/oidc" "github.com/traPtitech/portal-oidc/internal/repository/portal" v1 "github.com/traPtitech/portal-oidc/internal/router/v1" @@ -34,7 +35,7 @@ func newServer(cfg Config) (http.Handler, error) { return nil, fmt.Errorf("failed to load/generate RSA key: %w", err) } - oauthStorage := repository.NewOAuthStorage(queries) + oauthStorage := oauth.NewStorage(queries) defaults := defaultOAuthProviderConfig() oauth2Provider := newOAuthProvider(oauthStorage, OAuthProviderConfig{ Issuer: cfg.Host, diff --git a/internal/repository/oauth/authcode.go b/internal/repository/oauth/authcode.go new file mode 100644 index 0000000..2887709 --- /dev/null +++ b/internal/repository/oauth/authcode.go @@ -0,0 +1,120 @@ +package oauth + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +func (s *Storage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { + sess, ok := request.GetSession().(*Session) + if !ok { + return errors.New("invalid session type") + } + + return s.queries.CreateAuthorizationCode(ctx, oidc.CreateAuthorizationCodeParams{ + Code: code, + ClientID: request.GetClient().GetID(), + UserID: sess.GetSubject(), + RedirectUri: request.GetRequestForm().Get("redirect_uri"), + Scopes: strings.Join(request.GetRequestedScopes(), " "), + CodeChallenge: sql.NullString{Valid: false}, + CodeChallengeMethod: sql.NullString{Valid: false}, + Nonce: sql.NullString{ + String: request.GetRequestForm().Get("nonce"), + Valid: request.GetRequestForm().Get("nonce") != "", + }, + ExpiresAt: sess.GetExpiresAt(fosite.AuthorizeCode), + }) +} + +func (s *Storage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) { + dbCode, err := s.queries.GetAuthorizationCode(ctx, code) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + if time.Now().After(dbCode.ExpiresAt) { + return nil, fosite.ErrTokenExpired + } + + client, err := s.GetClient(ctx, dbCode.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbCode.Scopes, " ") + if dbCode.Scopes == "" { + scopes = []string{} + } + + sess := NewSession(dbCode.UserID) + sess.SetExpiresAt(fosite.AuthorizeCode, dbCode.ExpiresAt) + + form := make(map[string][]string) + form["redirect_uri"] = []string{dbCode.RedirectUri} + if dbCode.CodeChallenge.Valid { + form["code_challenge"] = []string{dbCode.CodeChallenge.String} + } + if dbCode.CodeChallengeMethod.Valid { + form["code_challenge_method"] = []string{dbCode.CodeChallengeMethod.String} + } + if dbCode.Nonce.Valid { + form["nonce"] = []string{dbCode.Nonce.String} + } + + req := &fosite.Request{ + ID: code, + RequestedAt: dbCode.CreatedAt, + Client: client, + Form: form, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *Storage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error { + return s.queries.DeleteAuthorizationCode(ctx, code) +} + +func (s *Storage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + return s.GetAuthorizeCodeSession(ctx, signature, session) +} + +func (s *Storage) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error { + challenge := requester.GetRequestForm().Get("code_challenge") + method := requester.GetRequestForm().Get("code_challenge_method") + + if challenge == "" { + return nil + } + + return s.queries.UpdateAuthorizationCodePKCE(ctx, oidc.UpdateAuthorizationCodePKCEParams{ + CodeChallenge: sql.NullString{ + String: challenge, + Valid: true, + }, + CodeChallengeMethod: sql.NullString{ + String: method, + Valid: method != "", + }, + Code: signature, + }) +} + +func (s *Storage) DeletePKCERequestSession(ctx context.Context, signature string) error { + return nil +} diff --git a/internal/repository/oauth/client.go b/internal/repository/oauth/client.go new file mode 100644 index 0000000..aaed517 --- /dev/null +++ b/internal/repository/oauth/client.go @@ -0,0 +1,36 @@ +package oauth + +import ( + "github.com/ory/fosite" + "golang.org/x/crypto/bcrypt" +) + +var _ fosite.Client = (*Client)(nil) + +type Client struct { + ID string + Secret []byte + RedirectURIs []string + GrantTypes []string + ResponseTypes []string + Scopes []string + Public bool +} + +func (c *Client) GetID() string { return c.ID } +func (c *Client) GetHashedSecret() []byte { return c.Secret } +func (c *Client) GetRedirectURIs() []string { return c.RedirectURIs } +func (c *Client) GetGrantTypes() fosite.Arguments { return c.GrantTypes } +func (c *Client) GetResponseTypes() fosite.Arguments { + if len(c.ResponseTypes) == 0 { + return []string{"code"} + } + return c.ResponseTypes +} +func (c *Client) GetScopes() fosite.Arguments { return c.Scopes } +func (c *Client) IsPublic() bool { return c.Public } +func (c *Client) GetAudience() fosite.Arguments { return nil } + +func ValidateClientSecret(hashedSecret []byte, secret string) bool { + return bcrypt.CompareHashAndPassword(hashedSecret, []byte(secret)) == nil +} diff --git a/internal/repository/oauth/oidc.go b/internal/repository/oauth/oidc.go new file mode 100644 index 0000000..644e1f7 --- /dev/null +++ b/internal/repository/oauth/oidc.go @@ -0,0 +1,31 @@ +package oauth + +import ( + "context" + + "github.com/ory/fosite" +) + +func (s *Storage) CreateOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) error { + s.oidcSessionsMutex.Lock() + defer s.oidcSessionsMutex.Unlock() + s.oidcSessions[authorizeCode] = requester + return nil +} + +func (s *Storage) GetOpenIDConnectSession(_ context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { + s.oidcSessionsMutex.RLock() + defer s.oidcSessionsMutex.RUnlock() + req, ok := s.oidcSessions[authorizeCode] + if !ok { + return nil, fosite.ErrNotFound + } + return req, nil +} + +func (s *Storage) DeleteOpenIDConnectSession(_ context.Context, authorizeCode string) error { + s.oidcSessionsMutex.Lock() + defer s.oidcSessionsMutex.Unlock() + delete(s.oidcSessions, authorizeCode) + return nil +} diff --git a/internal/repository/oauth/session.go b/internal/repository/oauth/session.go new file mode 100644 index 0000000..6118852 --- /dev/null +++ b/internal/repository/oauth/session.go @@ -0,0 +1,76 @@ +package oauth + +import ( + "maps" + "time" + + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/token/jwt" +) + +var _ openid.Session = (*Session)(nil) + +type Session struct { + subject string + username string + expiresAt map[fosite.TokenType]time.Time + extra map[string]interface{} + idTokenClaims *jwt.IDTokenClaims + idTokenHeaders *jwt.Headers +} + +func NewSession(subject string) *Session { + now := time.Now() + return &Session{ + subject: subject, + username: subject, + expiresAt: make(map[fosite.TokenType]time.Time), + extra: make(map[string]interface{}), + idTokenClaims: &jwt.IDTokenClaims{ + Subject: subject, + AuthTime: now, + }, + idTokenHeaders: &jwt.Headers{ + Extra: make(map[string]interface{}), + }, + } +} + +func (s *Session) SetExpiresAt(key fosite.TokenType, exp time.Time) { + if s.expiresAt == nil { + s.expiresAt = make(map[fosite.TokenType]time.Time) + } + s.expiresAt[key] = exp +} + +func (s *Session) GetExpiresAt(key fosite.TokenType) time.Time { + if s.expiresAt == nil { + return time.Time{} + } + return s.expiresAt[key] +} + +func (s *Session) GetUsername() string { return s.username } +func (s *Session) GetSubject() string { return s.subject } +func (s *Session) IDTokenClaims() *jwt.IDTokenClaims { return s.idTokenClaims } +func (s *Session) IDTokenHeaders() *jwt.Headers { return s.idTokenHeaders } + +func (s *Session) Clone() fosite.Session { + expiresAt := make(map[fosite.TokenType]time.Time) + maps.Copy(expiresAt, s.expiresAt) + extra := make(map[string]interface{}) + maps.Copy(extra, s.extra) + idTokenClaimsClone := *s.idTokenClaims + idTokenHeadersClone := *s.idTokenHeaders + idTokenHeadersClone.Extra = make(map[string]interface{}) + maps.Copy(idTokenHeadersClone.Extra, s.idTokenHeaders.Extra) + return &Session{ + subject: s.subject, + username: s.username, + expiresAt: expiresAt, + extra: extra, + idTokenClaims: &idTokenClaimsClone, + idTokenHeaders: &idTokenHeadersClone, + } +} diff --git a/internal/repository/oauth/storage.go b/internal/repository/oauth/storage.go new file mode 100644 index 0000000..0ce4862 --- /dev/null +++ b/internal/repository/oauth/storage.go @@ -0,0 +1,62 @@ +package oauth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "sync" + "time" + + "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +var _ fosite.Storage = (*Storage)(nil) + +type Storage struct { + queries *oidc.Queries + oidcSessions map[string]fosite.Requester + oidcSessionsMutex sync.RWMutex +} + +func NewStorage(queries *oidc.Queries) *Storage { + return &Storage{ + queries: queries, + oidcSessions: make(map[string]fosite.Requester), + } +} + +func (s *Storage) GetClient(ctx context.Context, id string) (fosite.Client, error) { + dbClient, err := s.queries.GetClient(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + var redirectURIs []string + if err := json.Unmarshal(dbClient.RedirectUris, &redirectURIs); err != nil { + return nil, err + } + + return &Client{ + ID: dbClient.ClientID, + Secret: []byte(dbClient.ClientSecretHash.String), + RedirectURIs: redirectURIs, + GrantTypes: []string{"authorization_code", "refresh_token"}, + ResponseTypes: []string{"code"}, + Scopes: []string{"openid", "profile", "email"}, + Public: dbClient.ClientType == "public", + }, nil +} + +func (s *Storage) ClientAssertionJWTValid(ctx context.Context, jti string) error { + return fosite.ErrNotFound +} + +func (s *Storage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { + return nil +} diff --git a/internal/repository/oauth/token.go b/internal/repository/oauth/token.go new file mode 100644 index 0000000..fe149b1 --- /dev/null +++ b/internal/repository/oauth/token.go @@ -0,0 +1,153 @@ +package oauth + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +func (s *Storage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { + sess, ok := request.GetSession().(*Session) + if !ok { + return errors.New("invalid session type") + } + + tokenID := uuid.New() + return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ + ID: tokenID.String(), + ClientID: request.GetClient().GetID(), + UserID: sess.GetSubject(), + AccessToken: signature, + RefreshToken: sql.NullString{ + Valid: false, + }, + Scopes: strings.Join(request.GetGrantedScopes(), " "), + ExpiresAt: sess.GetExpiresAt(fosite.AccessToken), + }) +} + +func (s *Storage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + dbToken, err := s.queries.GetTokenByAccessToken(ctx, signature) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + if time.Now().After(dbToken.ExpiresAt) { + return nil, fosite.ErrTokenExpired + } + + client, err := s.GetClient(ctx, dbToken.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbToken.Scopes, " ") + if dbToken.Scopes == "" { + scopes = []string{} + } + + sess := NewSession(dbToken.UserID) + sess.SetExpiresAt(fosite.AccessToken, dbToken.ExpiresAt) + + req := &fosite.Request{ + ID: dbToken.ID, + RequestedAt: dbToken.CreatedAt, + Client: client, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *Storage) DeleteAccessTokenSession(ctx context.Context, signature string) error { + return s.queries.DeleteTokenByAccessToken(ctx, signature) +} + +func (s *Storage) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error { + sess, ok := request.GetSession().(*Session) + if !ok { + return errors.New("invalid session type") + } + + tokenID := uuid.New() + return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ + ID: tokenID.String(), + ClientID: request.GetClient().GetID(), + UserID: sess.GetSubject(), + AccessToken: accessSignature, + RefreshToken: sql.NullString{ + String: signature, + Valid: true, + }, + Scopes: strings.Join(request.GetGrantedScopes(), " "), + ExpiresAt: sess.GetExpiresAt(fosite.RefreshToken), + }) +} + +func (s *Storage) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error { + return nil +} + +func (s *Storage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { + dbToken, err := s.queries.GetTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fosite.ErrNotFound + } + return nil, err + } + + client, err := s.GetClient(ctx, dbToken.ClientID) + if err != nil { + return nil, err + } + + scopes := strings.Split(dbToken.Scopes, " ") + if dbToken.Scopes == "" { + scopes = []string{} + } + + sess := NewSession(dbToken.UserID) + sess.SetExpiresAt(fosite.RefreshToken, dbToken.ExpiresAt) + + req := &fosite.Request{ + ID: dbToken.ID, + RequestedAt: dbToken.CreatedAt, + Client: client, + Session: sess, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req, nil +} + +func (s *Storage) DeleteRefreshTokenSession(ctx context.Context, signature string) error { + return s.queries.DeleteTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) +} + +func (s *Storage) RevokeRefreshToken(ctx context.Context, requestID string) error { + return nil +} + +func (s *Storage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error { + return s.DeleteRefreshTokenSession(ctx, signature) +} + +func (s *Storage) RevokeAccessToken(ctx context.Context, requestID string) error { + return nil +} diff --git a/internal/repository/oauth_storage.go b/internal/repository/oauth_storage.go deleted file mode 100644 index 73093e0..0000000 --- a/internal/repository/oauth_storage.go +++ /dev/null @@ -1,444 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "strings" - "sync" - "time" - - "github.com/google/uuid" - "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" - "github.com/ory/fosite/token/jwt" - "golang.org/x/crypto/bcrypt" - - "github.com/traPtitech/portal-oidc/internal/repository/oidc" -) - -// OAuthStorage implements fosite.Storage interface -type OAuthStorage struct { - queries *oidc.Queries - oidcSessions map[string]fosite.Requester - oidcSessionsMutex sync.RWMutex -} - -func NewOAuthStorage(queries *oidc.Queries) *OAuthStorage { - return &OAuthStorage{ - queries: queries, - oidcSessions: make(map[string]fosite.Requester), - } -} - -// ClientCredentials implements fosite.ClientCredentialsStorage -func (s *OAuthStorage) GetClient(ctx context.Context, id string) (fosite.Client, error) { - dbClient, err := s.queries.GetClient(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fosite.ErrNotFound - } - return nil, err - } - - var redirectURIs []string - if err := json.Unmarshal(dbClient.RedirectUris, &redirectURIs); err != nil { - return nil, err - } - - return &OAuthClient{ - ID: dbClient.ClientID, - Secret: []byte(dbClient.ClientSecretHash.String), - RedirectURIs: redirectURIs, - GrantTypes: []string{"authorization_code", "refresh_token"}, - ResponseTypes: []string{"code"}, - Scopes: []string{"openid", "profile", "email"}, - Public: dbClient.ClientType == "public", - }, nil -} - -func (s *OAuthStorage) ClientAssertionJWTValid(ctx context.Context, jti string) error { - return fosite.ErrNotFound -} - -func (s *OAuthStorage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { - return nil -} - -// AuthorizeCodeStorage implements fosite.AuthorizeCodeStorage -func (s *OAuthStorage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { - sess, ok := request.GetSession().(*OAuthSession) - if !ok { - return errors.New("invalid session type") - } - - return s.queries.CreateAuthorizationCode(ctx, oidc.CreateAuthorizationCodeParams{ - Code: code, - ClientID: request.GetClient().GetID(), - UserID: sess.Subject, - RedirectUri: request.GetRequestForm().Get("redirect_uri"), - Scopes: strings.Join(request.GetRequestedScopes(), " "), - CodeChallenge: sql.NullString{Valid: false}, - CodeChallengeMethod: sql.NullString{Valid: false}, - Nonce: sql.NullString{ - String: request.GetRequestForm().Get("nonce"), - Valid: request.GetRequestForm().Get("nonce") != "", - }, - ExpiresAt: sess.ExpiresAt[fosite.AuthorizeCode], - }) -} - -func (s *OAuthStorage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) { - dbCode, err := s.queries.GetAuthorizationCode(ctx, code) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fosite.ErrNotFound - } - return nil, err - } - - if time.Now().After(dbCode.ExpiresAt) { - return nil, fosite.ErrTokenExpired - } - - client, err := s.GetClient(ctx, dbCode.ClientID) - if err != nil { - return nil, err - } - - scopes := strings.Split(dbCode.Scopes, " ") - if dbCode.Scopes == "" { - scopes = []string{} - } - - sess := NewOAuthSession(dbCode.UserID) - sess.ExpiresAt[fosite.AuthorizeCode] = dbCode.ExpiresAt - - form := make(map[string][]string) - form["redirect_uri"] = []string{dbCode.RedirectUri} - if dbCode.CodeChallenge.Valid { - form["code_challenge"] = []string{dbCode.CodeChallenge.String} - } - if dbCode.CodeChallengeMethod.Valid { - form["code_challenge_method"] = []string{dbCode.CodeChallengeMethod.String} - } - if dbCode.Nonce.Valid { - form["nonce"] = []string{dbCode.Nonce.String} - } - - req := &fosite.Request{ - ID: code, - RequestedAt: dbCode.CreatedAt, - Client: client, - Form: form, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) - } - return req, nil -} - -func (s *OAuthStorage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error { - return s.queries.DeleteAuthorizationCode(ctx, code) -} - -// PKCERequestStorage implements fosite.PKCERequestStorage -func (s *OAuthStorage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { - return s.GetAuthorizeCodeSession(ctx, signature, session) -} - -func (s *OAuthStorage) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error { - challenge := requester.GetRequestForm().Get("code_challenge") - method := requester.GetRequestForm().Get("code_challenge_method") - - if challenge == "" { - return nil - } - - return s.queries.UpdateAuthorizationCodePKCE(ctx, oidc.UpdateAuthorizationCodePKCEParams{ - CodeChallenge: sql.NullString{ - String: challenge, - Valid: true, - }, - CodeChallengeMethod: sql.NullString{ - String: method, - Valid: method != "", - }, - Code: signature, - }) -} - -func (s *OAuthStorage) DeletePKCERequestSession(ctx context.Context, signature string) error { - return nil -} - -// AccessTokenStorage implements fosite.AccessTokenStorage -func (s *OAuthStorage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { - sess, ok := request.GetSession().(*OAuthSession) - if !ok { - return errors.New("invalid session type") - } - - tokenID := uuid.New() - return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ - ID: tokenID.String(), - ClientID: request.GetClient().GetID(), - UserID: sess.Subject, - AccessToken: signature, - RefreshToken: sql.NullString{ - Valid: false, - }, - Scopes: strings.Join(request.GetGrantedScopes(), " "), - ExpiresAt: sess.ExpiresAt[fosite.AccessToken], - }) -} - -func (s *OAuthStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { - dbToken, err := s.queries.GetTokenByAccessToken(ctx, signature) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fosite.ErrNotFound - } - return nil, err - } - - if time.Now().After(dbToken.ExpiresAt) { - return nil, fosite.ErrTokenExpired - } - - client, err := s.GetClient(ctx, dbToken.ClientID) - if err != nil { - return nil, err - } - - scopes := strings.Split(dbToken.Scopes, " ") - if dbToken.Scopes == "" { - scopes = []string{} - } - - sess := NewOAuthSession(dbToken.UserID) - sess.ExpiresAt[fosite.AccessToken] = dbToken.ExpiresAt - - req := &fosite.Request{ - ID: dbToken.ID, - RequestedAt: dbToken.CreatedAt, - Client: client, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) - } - return req, nil -} - -func (s *OAuthStorage) DeleteAccessTokenSession(ctx context.Context, signature string) error { - return s.queries.DeleteTokenByAccessToken(ctx, signature) -} - -// RefreshTokenStorage implements fosite.RefreshTokenStorage -func (s *OAuthStorage) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error { - sess, ok := request.GetSession().(*OAuthSession) - if !ok { - return errors.New("invalid session type") - } - - tokenID := uuid.New() - return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ - ID: tokenID.String(), - ClientID: request.GetClient().GetID(), - UserID: sess.Subject, - AccessToken: accessSignature, - RefreshToken: sql.NullString{ - String: signature, - Valid: true, - }, - Scopes: strings.Join(request.GetGrantedScopes(), " "), - ExpiresAt: sess.ExpiresAt[fosite.RefreshToken], - }) -} - -func (s *OAuthStorage) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error { - return nil -} - -func (s *OAuthStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { - dbToken, err := s.queries.GetTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fosite.ErrNotFound - } - return nil, err - } - - client, err := s.GetClient(ctx, dbToken.ClientID) - if err != nil { - return nil, err - } - - scopes := strings.Split(dbToken.Scopes, " ") - if dbToken.Scopes == "" { - scopes = []string{} - } - - sess := NewOAuthSession(dbToken.UserID) - sess.ExpiresAt[fosite.RefreshToken] = dbToken.ExpiresAt - - req := &fosite.Request{ - ID: dbToken.ID, - RequestedAt: dbToken.CreatedAt, - Client: client, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) - } - return req, nil -} - -func (s *OAuthStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) error { - return s.queries.DeleteTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) -} - -func (s *OAuthStorage) RevokeRefreshToken(ctx context.Context, requestID string) error { - return nil -} - -func (s *OAuthStorage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error { - return s.DeleteRefreshTokenSession(ctx, signature) -} - -func (s *OAuthStorage) RevokeAccessToken(ctx context.Context, requestID string) error { - return nil -} - -// OpenIDConnectRequestStorage implementation - -func (s *OAuthStorage) CreateOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) error { - s.oidcSessionsMutex.Lock() - defer s.oidcSessionsMutex.Unlock() - s.oidcSessions[authorizeCode] = requester - return nil -} - -func (s *OAuthStorage) GetOpenIDConnectSession(_ context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { - s.oidcSessionsMutex.RLock() - defer s.oidcSessionsMutex.RUnlock() - req, ok := s.oidcSessions[authorizeCode] - if !ok { - return nil, fosite.ErrNotFound - } - return req, nil -} - -func (s *OAuthStorage) DeleteOpenIDConnectSession(_ context.Context, authorizeCode string) error { - s.oidcSessionsMutex.Lock() - defer s.oidcSessionsMutex.Unlock() - delete(s.oidcSessions, authorizeCode) - return nil -} - -// OAuthClient implements fosite.Client -type OAuthClient struct { - ID string - Secret []byte - RedirectURIs []string - GrantTypes []string - ResponseTypes []string - Scopes []string - Public bool -} - -func (c *OAuthClient) GetID() string { return c.ID } -func (c *OAuthClient) GetHashedSecret() []byte { return c.Secret } -func (c *OAuthClient) GetRedirectURIs() []string { return c.RedirectURIs } -func (c *OAuthClient) GetGrantTypes() fosite.Arguments { return c.GrantTypes } -func (c *OAuthClient) GetResponseTypes() fosite.Arguments { - if len(c.ResponseTypes) == 0 { - return []string{"code"} - } - return c.ResponseTypes -} -func (c *OAuthClient) GetScopes() fosite.Arguments { return c.Scopes } -func (c *OAuthClient) IsPublic() bool { return c.Public } -func (c *OAuthClient) GetAudience() fosite.Arguments { return nil } - -// OAuthSession implements fosite.Session and openid.Session -type OAuthSession struct { - Subject string - Username string - ExpiresAt map[fosite.TokenType]time.Time - Extra map[string]interface{} - idTokenClaims *jwt.IDTokenClaims - idTokenHeaders *jwt.Headers -} - -var _ openid.Session = (*OAuthSession)(nil) - -func NewOAuthSession(subject string) *OAuthSession { - return &OAuthSession{ - Subject: subject, - Username: subject, - ExpiresAt: make(map[fosite.TokenType]time.Time), - Extra: make(map[string]interface{}), - idTokenClaims: &jwt.IDTokenClaims{ - Subject: subject, - }, - idTokenHeaders: &jwt.Headers{ - Extra: make(map[string]interface{}), - }, - } -} - -func (s *OAuthSession) SetExpiresAt(key fosite.TokenType, exp time.Time) { - if s.ExpiresAt == nil { - s.ExpiresAt = make(map[fosite.TokenType]time.Time) - } - s.ExpiresAt[key] = exp -} - -func (s *OAuthSession) GetExpiresAt(key fosite.TokenType) time.Time { - if s.ExpiresAt == nil { - return time.Time{} - } - return s.ExpiresAt[key] -} - -func (s *OAuthSession) GetUsername() string { return s.Username } -func (s *OAuthSession) GetSubject() string { return s.Subject } -func (s *OAuthSession) IDTokenClaims() *jwt.IDTokenClaims { return s.idTokenClaims } -func (s *OAuthSession) IDTokenHeaders() *jwt.Headers { return s.idTokenHeaders } - -func (s *OAuthSession) Clone() fosite.Session { - expiresAt := make(map[fosite.TokenType]time.Time) - for k, v := range s.ExpiresAt { - expiresAt[k] = v - } - extra := make(map[string]interface{}) - for k, v := range s.Extra { - extra[k] = v - } - idTokenClaimsClone := *s.idTokenClaims - idTokenHeadersClone := *s.idTokenHeaders - idTokenHeadersClone.Extra = make(map[string]interface{}) - for k, v := range s.idTokenHeaders.Extra { - idTokenHeadersClone.Extra[k] = v - } - return &OAuthSession{ - Subject: s.Subject, - Username: s.Username, - ExpiresAt: expiresAt, - Extra: extra, - idTokenClaims: &idTokenClaimsClone, - idTokenHeaders: &idTokenHeadersClone, - } -} - -// ValidateClientSecret validates the client secret using bcrypt -func ValidateClientSecret(hashedSecret []byte, secret string) bool { - return bcrypt.CompareHashAndPassword(hashedSecret, []byte(secret)) == nil -} diff --git a/internal/router/v1/client_test.go b/internal/router/v1/client_test.go index b244c23..fced327 100644 --- a/internal/router/v1/client_test.go +++ b/internal/router/v1/client_test.go @@ -22,6 +22,7 @@ import ( "github.com/ory/fosite/compose" "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oauth" "github.com/traPtitech/portal-oidc/internal/repository/oidc" "github.com/traPtitech/portal-oidc/internal/router/v1/gen" "github.com/traPtitech/portal-oidc/internal/testutil" @@ -122,7 +123,7 @@ func setupTestHandler(t *testing.T) (*Handler, func()) { clientRepo := repository.NewClientRepository(queries) clientUseCase := usecase.NewClientUseCase(clientRepo) - oauthStorage := repository.NewOAuthStorage(queries) + oauthStorage := oauth.NewStorage(queries) fositeConfig := &fosite.Config{ AccessTokenLifespan: time.Hour, RefreshTokenLifespan: 30 * 24 * time.Hour, diff --git a/internal/router/v1/oauth.go b/internal/router/v1/oauth.go index 1fa3cd8..f80fede 100644 --- a/internal/router/v1/oauth.go +++ b/internal/router/v1/oauth.go @@ -12,7 +12,7 @@ import ( "github.com/labstack/echo/v4" "github.com/ory/fosite" - "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/repository/oauth" "github.com/traPtitech/portal-oidc/internal/router/v1/gen" ) @@ -38,7 +38,7 @@ func (h *Handler) Authorize(ctx echo.Context, params gen.AuthorizeParams) error return nil } - session := repository.NewOAuthSession(userID) + session := oauth.NewSession(userID) for _, scope := range ar.GetRequestedScopes() { ar.GrantScope(scope) } @@ -58,7 +58,7 @@ func (h *Handler) Token(ctx echo.Context) error { rw := ctx.Response() req := ctx.Request() - session := repository.NewOAuthSession("") + session := oauth.NewSession("") accessRequest, err := h.oauth2.NewAccessRequest(c, req, session) if err != nil { h.oauth2.WriteAccessError(c, rw, accessRequest, err) @@ -116,7 +116,7 @@ func (h *Handler) extractBearerToken(ctx echo.Context) (string, error) { func (h *Handler) handleUserInfo(ctx echo.Context, token string) error { c := ctx.Request().Context() - _, ar, err := h.oauth2.IntrospectToken(c, token, fosite.AccessToken, repository.NewOAuthSession("")) + _, ar, err := h.oauth2.IntrospectToken(c, token, fosite.AccessToken, oauth.NewSession("")) if err != nil { return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidGrant}) } @@ -125,6 +125,9 @@ func (h *Handler) handleUserInfo(ctx echo.Context, token string) error { } func (h *Handler) GetJWKS(ctx echo.Context) error { + if h.config.PrivateKey == nil { + return echo.NewHTTPError(http.StatusInternalServerError, "signing key not configured") + } pubKey := &h.config.PrivateKey.PublicKey hash := sha256.Sum256(pubKey.N.Bytes()) @@ -143,7 +146,7 @@ func (h *Handler) GetJWKS(ctx echo.Context) error { } func (h *Handler) GetOpenIDConfiguration(ctx echo.Context) error { - issuer := h.config.Issuer + issuer := strings.TrimRight(h.config.Issuer, "/") scopesSupported := []string{"openid", "profile", "email"} claimsSupported := []string{"sub", "name", "preferred_username", "email", "email_verified"} codeChallengeMethodsSupported := []string{"S256", "plain"} From bf51fc99a702f647101dfcedb10cb80660450dc5 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:05:30 +0900 Subject: [PATCH 15/26] chore: update Go, tooling, and configuration - Update Go to 1.25.7 for security fixes - Manage oapi-codegen via mise instead of go tool directive - Use double underscore as koanf env separator - Remove bearerFormat from OpenAPI security scheme Assisted-by: Claude Code (model: claude-opus-4-6) --- api/openapi.yaml | 1 - cmd/config.go | 6 +++--- compose.yaml | 6 +++--- go.mod | 3 ++- go.sum | 4 ++-- mise.toml | 5 +++-- 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 193bdc6..5759dac 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -600,4 +600,3 @@ components: bearerAuth: type: http scheme: bearer - bearerFormat: JWT diff --git a/cmd/config.go b/cmd/config.go index 1669e6b..7efdf4f 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -27,12 +27,12 @@ type DatabaseConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` User string `koanf:"user"` - Password string `koanf:"password"` + Password string `koanf:"password"` // #nosec G117 -- config struct, not serialized Name string `koanf:"name"` } type OAuthConfig struct { - Secret string `koanf:"secret"` + Secret string `koanf:"secret"` // #nosec G117 -- config struct, not serialized KeyFile string `koanf:"key_file"` TestUserID string `koanf:"test_user_id"` } @@ -77,7 +77,7 @@ func loadConfig(configPath string) (*Config, error) { if err := k.Load(env.Provider("OIDC_", ".", func(s string) string { key := strings.ToLower(strings.TrimPrefix(s, "OIDC_")) - return strings.ReplaceAll(key, "_", ".") + return strings.ReplaceAll(key, "__", ".") }), nil); err != nil { return nil, err } diff --git a/compose.yaml b/compose.yaml index bcb48b8..57f59c4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,9 +13,9 @@ services: environment: - CONFIG_FILE=/app/config.yaml - TZ=Asia/Tokyo - - OIDC_DATABASE_HOST=oidc - - OIDC_DATABASE_PORT=3306 - - OIDC_PORTAL_DATABASE_HOST=portal + - OIDC_DATABASE__HOST=oidc + - OIDC_DATABASE__PORT=3306 + - OIDC_PORTAL__DATABASE__HOST=portal depends_on: portal: condition: service_healthy diff --git a/go.mod b/go.mod index 98b5551..efeae42 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/traPtitech/portal-oidc -go 1.25.5 +go 1.25.7 require ( github.com/alecthomas/kong v1.13.0 @@ -63,6 +63,7 @@ require ( github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.9.5 // indirect diff --git a/go.sum b/go.sum index ace8efe..c51be6a 100644 --- a/go.sum +++ b/go.sum @@ -379,8 +379,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= diff --git a/mise.toml b/mise.toml index 3105baa..e51c182 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,7 @@ atlas = "1.0.0" tbls = "1.92.3" pre-commit = "4.5.1" "go:github.com/sqlc-dev/sqlc/cmd/sqlc" = "1.30.0" +"go:github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" = "2.5.1" # ============================================================================= # Development @@ -59,8 +60,8 @@ outputs = [ description = "OpenAPIでサーバー/モデル生成" dir = "api" run = """ -go tool oapi-codegen --config oapi.server.yaml openapi.yaml -go tool oapi-codegen --config oapi.models.yaml openapi.yaml +oapi-codegen --config oapi.server.yaml openapi.yaml +oapi-codegen --config oapi.models.yaml openapi.yaml """ sources = [ "openapi.yaml", From 09ffddd64d53d5bde3d473be5cb194118b0c0999 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:07:19 +0900 Subject: [PATCH 16/26] feat(oauth): add domain types, repository layer, and transactional storage - Add domain types for AuthCode, Token, OIDCSession, and User - Add repository interfaces for AuthCode, Token, Client, and OIDCSession - Add OIDC session database schema with auth_time column - Refactor OAuth storage to use repository interfaces with transaction support - Implement compile-time interface checks for fosite storage contracts - Add GetWithSecretHash to ClientRepository for OAuth client lookup - Change NewSession to accept authTime parameter for OIDC conformance Assisted-by: Claude Code (model: claude-opus-4-6) --- cmd/oauth.go | 6 +- cmd/serve.go | 24 +++-- db/query/oidc.sql | 33 +++++- db/schema.sql | 18 ++++ internal/domain/client.go | 2 +- internal/domain/oauth.go | 40 +++++++ internal/domain/user.go | 11 ++ internal/repository/authcode.go | 111 ++++++++++++++++++++ internal/repository/client.go | 18 ++++ internal/repository/oauth/authcode.go | 89 ++++++---------- internal/repository/oauth/client.go | 2 +- internal/repository/oauth/oidc.go | 60 ++++++++--- internal/repository/oauth/session.go | 8 +- internal/repository/oauth/storage.go | 143 ++++++++++++++++++++++---- internal/repository/oauth/token.go | 108 ++++++------------- internal/repository/oidc/db.go | 60 +++++++++++ internal/repository/oidc/models.go | 13 +++ internal/repository/oidc/oidc.sql.go | 110 +++++++++++++++++++- internal/repository/oidc/querier.go | 7 ++ internal/repository/oidcsession.go | 71 +++++++++++++ internal/repository/token.go | 120 +++++++++++++++++++++ internal/router/v1/oauth.go | 7 +- 22 files changed, 864 insertions(+), 197 deletions(-) create mode 100644 internal/domain/oauth.go create mode 100644 internal/domain/user.go create mode 100644 internal/repository/authcode.go create mode 100644 internal/repository/oidcsession.go create mode 100644 internal/repository/token.go diff --git a/cmd/oauth.go b/cmd/oauth.go index 681d8ff..5fed6d2 100644 --- a/cmd/oauth.go +++ b/cmd/oauth.go @@ -15,8 +15,6 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/compose" "github.com/ory/fosite/token/jwt" - - "github.com/traPtitech/portal-oidc/internal/repository/oauth" ) type OAuthProviderConfig struct { @@ -25,7 +23,7 @@ type OAuthProviderConfig struct { RefreshTokenLifespan time.Duration AuthCodeLifespan time.Duration IDTokenLifespan time.Duration - Secret []byte + Secret []byte // #nosec G117 -- internal config, not serialized } func defaultOAuthProviderConfig() OAuthProviderConfig { @@ -39,7 +37,7 @@ func defaultOAuthProviderConfig() OAuthProviderConfig { } } -func newOAuthProvider(storage *oauth.Storage, config OAuthProviderConfig, privateKey *rsa.PrivateKey) fosite.OAuth2Provider { +func newOAuthProvider(storage fosite.Storage, config OAuthProviderConfig, privateKey *rsa.PrivateKey) fosite.OAuth2Provider { fositeConfig := &fosite.Config{ AccessTokenLifespan: config.AccessTokenLifespan, RefreshTokenLifespan: config.RefreshTokenLifespan, diff --git a/cmd/serve.go b/cmd/serve.go index 8738d4b..b4afaca 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -20,7 +20,7 @@ import ( ) func newServer(cfg Config) (http.Handler, error) { - queries, err := setupOIDCDatabase(cfg.Database) + oidcDB, queries, err := setupOIDCDatabase(cfg.Database) if err != nil { return nil, err } @@ -35,7 +35,15 @@ func newServer(cfg Config) (http.Handler, error) { return nil, fmt.Errorf("failed to load/generate RSA key: %w", err) } - oauthStorage := oauth.NewStorage(queries) + clientRepo := repository.NewClientRepository(queries) + oauthStorage := oauth.NewStorage( + oidcDB, + queries, + clientRepo, + repository.NewAuthCodeRepository(queries), + repository.NewTokenRepository(queries), + repository.NewOIDCSessionRepository(queries), + ) defaults := defaultOAuthProviderConfig() oauth2Provider := newOAuthProvider(oauthStorage, OAuthProviderConfig{ Issuer: cfg.Host, @@ -47,7 +55,7 @@ func newServer(cfg Config) (http.Handler, error) { }, privateKey) handler := v1.NewHandler( - usecase.NewClientUseCase(repository.NewClientRepository(queries)), + usecase.NewClientUseCase(clientRepo), oauth2Provider, repository.NewUserRepository(portalQueries), v1.OAuthConfig{ @@ -77,25 +85,25 @@ func newServer(cfg Config) (http.Handler, error) { return e, nil } -func setupOIDCDatabase(cfg DatabaseConfig) (*oidc.Queries, error) { +func setupOIDCDatabase(cfg DatabaseConfig) (*sql.DB, *oidc.Queries, error) { dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Name) db, err := sql.Open("mysql", dsn) if err != nil { - return nil, fmt.Errorf("failed to open oidc database: %w", err) + return nil, nil, fmt.Errorf("failed to open oidc database: %w", err) } if err := db.PingContext(context.Background()); err != nil { - return nil, fmt.Errorf("failed to ping oidc database: %w", err) + return nil, nil, fmt.Errorf("failed to ping oidc database: %w", err) } queries, err := oidc.Prepare(context.Background(), db) if err != nil { - return nil, fmt.Errorf("failed to prepare oidc queries: %w", err) + return nil, nil, fmt.Errorf("failed to prepare oidc queries: %w", err) } - return queries, nil + return db, queries, nil } func setupPortalDatabase(cfg DatabaseConfig) (*portal.Queries, error) { diff --git a/db/query/oidc.sql b/db/query/oidc.sql index cd8d621..bc683e6 100644 --- a/db/query/oidc.sql +++ b/db/query/oidc.sql @@ -54,6 +54,9 @@ SELECT * FROM authorization_codes WHERE code = ?; -- name: DeleteAuthorizationCode :exec DELETE FROM authorization_codes WHERE code = ?; +-- name: MarkAuthorizationCodeUsed :exec +UPDATE authorization_codes SET used = TRUE WHERE code = ?; + -- name: UpdateAuthorizationCodePKCE :exec UPDATE authorization_codes SET code_challenge = ?, @@ -71,13 +74,14 @@ DELETE FROM authorization_codes; -- name: CreateToken :exec INSERT INTO tokens ( id, + request_id, client_id, user_id, access_token, refresh_token, scopes, expires_at -) VALUES (?, ?, ?, ?, ?, ?, ?); +) VALUES (?, ?, ?, ?, ?, ?, ?, ?); -- name: GetTokenByAccessToken :one SELECT * FROM tokens WHERE access_token = ?; @@ -103,5 +107,30 @@ DELETE FROM tokens WHERE expires_at < NOW(); -- name: DeleteTokensByUserAndClient :exec DELETE FROM tokens WHERE user_id = ? AND client_id = ?; +-- name: DeleteTokensByRequestID :exec +DELETE FROM tokens WHERE request_id = ?; + -- name: DeleteAllTokens :exec -DELETE FROM tokens; \ No newline at end of file +DELETE FROM tokens; + +-- OIDC Session queries + +-- name: CreateOIDCSession :exec +INSERT INTO oidc_sessions ( + authorize_code, + client_id, + user_id, + scopes, + nonce, + auth_time, + requested_at +) VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: GetOIDCSession :one +SELECT * FROM oidc_sessions WHERE authorize_code = ?; + +-- name: DeleteOIDCSession :exec +DELETE FROM oidc_sessions WHERE authorize_code = ?; + +-- name: DeleteAllOIDCSessions :exec +DELETE FROM oidc_sessions; \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index b77b445..15ffdad 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `authorization_codes` ( `code_challenge` varchar(128) NULL, `code_challenge_method` varchar(10) NULL, `nonce` varchar(255) NULL, + `used` BOOLEAN NOT NULL DEFAULT FALSE, `expires_at` datetime(6) NOT NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`code`), @@ -30,6 +31,7 @@ CREATE TABLE IF NOT EXISTS `authorization_codes` ( CREATE TABLE IF NOT EXISTS `tokens` ( `id` char(36) NOT NULL, + `request_id` varchar(64) NOT NULL, `client_id` char(36) NOT NULL, `user_id` char(36) NOT NULL, `access_token` varchar(64) NOT NULL, @@ -42,6 +44,22 @@ CREATE TABLE IF NOT EXISTS `tokens` ( UNIQUE INDEX `idx_tokens_refresh_token` (`refresh_token`), INDEX `idx_tokens_client_id` (`client_id`), INDEX `idx_tokens_user_id` (`user_id`), + INDEX `idx_tokens_request_id` (`request_id`), INDEX `idx_tokens_expires_at` (`expires_at`), CONSTRAINT `fk_tokens_client` FOREIGN KEY (`client_id`) REFERENCES `clients` (`client_id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE IF NOT EXISTS `oidc_sessions` ( + `authorize_code` varchar(64) NOT NULL, + `client_id` char(36) NOT NULL, + `user_id` char(36) NOT NULL, + `scopes` text NOT NULL, + `nonce` varchar(255) NULL, + `auth_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `requested_at` datetime(6) NOT NULL, + `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (`authorize_code`), + INDEX `idx_oidc_sessions_client_id` (`client_id`), + CONSTRAINT `fk_oidc_sessions_client` FOREIGN KEY (`client_id`) + REFERENCES `clients` (`client_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/internal/domain/client.go b/internal/domain/client.go index d8d64e4..1f9581d 100644 --- a/internal/domain/client.go +++ b/internal/domain/client.go @@ -24,5 +24,5 @@ type Client struct { type ClientWithSecret struct { Client - ClientSecret string + ClientSecret string // #nosec G117 -- returned only on creation, not persisted } diff --git a/internal/domain/oauth.go b/internal/domain/oauth.go new file mode 100644 index 0000000..bf95cc1 --- /dev/null +++ b/internal/domain/oauth.go @@ -0,0 +1,40 @@ +package domain + +import "time" + +type AuthCode struct { + Code string + ClientID string + UserID string + RedirectURI string + Scopes []string + CodeChallenge string + CodeChallengeMethod string + Nonce string + Used bool + ExpiresAt time.Time + CreatedAt time.Time +} + +type Token struct { + ID string + RequestID string + ClientID string + UserID string + AccessToken string // #nosec G117 -- domain field name, not a credential + RefreshToken string // #nosec G117 -- domain field name, not a credential + Scopes []string + ExpiresAt time.Time + CreatedAt time.Time +} + +type OIDCSession struct { + AuthorizeCode string + ClientID string + UserID string + Nonce string + AuthTime time.Time + Scopes []string + RequestedAt time.Time + CreatedAt time.Time +} diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..028a05f --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,11 @@ +package domain + +type User struct { + ID string + TrapID string +} + +type UserWithPassword struct { + User + PasswordHash string +} diff --git a/internal/repository/authcode.go b/internal/repository/authcode.go new file mode 100644 index 0000000..d6b41a0 --- /dev/null +++ b/internal/repository/authcode.go @@ -0,0 +1,111 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +var ErrAuthCodeNotFound = errors.New("authorization code not found") + +type AuthCodeRepository interface { + Create(ctx context.Context, authCode domain.AuthCode) error + Get(ctx context.Context, code string) (domain.AuthCode, error) + Delete(ctx context.Context, code string) error + MarkUsed(ctx context.Context, code string) error + UpdatePKCE(ctx context.Context, code, challenge, method string) error +} + +type authCodeRepository struct { + queries *oidc.Queries +} + +func NewAuthCodeRepository(queries *oidc.Queries) AuthCodeRepository { + return &authCodeRepository{queries: queries} +} + +func (r *authCodeRepository) Create(ctx context.Context, authCode domain.AuthCode) error { + return r.queries.CreateAuthorizationCode(ctx, oidc.CreateAuthorizationCodeParams{ + Code: authCode.Code, + ClientID: authCode.ClientID, + UserID: authCode.UserID, + RedirectUri: authCode.RedirectURI, + Scopes: strings.Join(authCode.Scopes, " "), + CodeChallenge: sql.NullString{ + String: authCode.CodeChallenge, + Valid: authCode.CodeChallenge != "", + }, + CodeChallengeMethod: sql.NullString{ + String: authCode.CodeChallengeMethod, + Valid: authCode.CodeChallengeMethod != "", + }, + Nonce: sql.NullString{ + String: authCode.Nonce, + Valid: authCode.Nonce != "", + }, + ExpiresAt: authCode.ExpiresAt, + }) +} + +func (r *authCodeRepository) Get(ctx context.Context, code string) (domain.AuthCode, error) { + dbCode, err := r.queries.GetAuthorizationCode(ctx, code) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.AuthCode{}, ErrAuthCodeNotFound + } + return domain.AuthCode{}, err + } + + return toDomainAuthCode(dbCode), nil +} + +func (r *authCodeRepository) Delete(ctx context.Context, code string) error { + return r.queries.DeleteAuthorizationCode(ctx, code) +} + +func (r *authCodeRepository) MarkUsed(ctx context.Context, code string) error { + return r.queries.MarkAuthorizationCodeUsed(ctx, code) +} + +func (r *authCodeRepository) UpdatePKCE(ctx context.Context, code, challenge, method string) error { + return r.queries.UpdateAuthorizationCodePKCE(ctx, oidc.UpdateAuthorizationCodePKCEParams{ + CodeChallenge: sql.NullString{ + String: challenge, + Valid: challenge != "", + }, + CodeChallengeMethod: sql.NullString{ + String: method, + Valid: method != "", + }, + Code: code, + }) +} + +func toDomainAuthCode(db oidc.AuthorizationCode) domain.AuthCode { + scopes := splitScopes(db.Scopes) + + return domain.AuthCode{ + Code: db.Code, + ClientID: db.ClientID, + UserID: db.UserID, + RedirectURI: db.RedirectUri, + Scopes: scopes, + CodeChallenge: db.CodeChallenge.String, + CodeChallengeMethod: db.CodeChallengeMethod.String, + Nonce: db.Nonce.String, + Used: db.Used, + ExpiresAt: db.ExpiresAt, + CreatedAt: db.CreatedAt, + } +} + +func splitScopes(s string) []string { + if s == "" { + return []string{} + } + return strings.Split(s, " ") +} diff --git a/internal/repository/client.go b/internal/repository/client.go index 90db1fb..e0f4cec 100644 --- a/internal/repository/client.go +++ b/internal/repository/client.go @@ -17,6 +17,7 @@ var ErrClientNotFound = errors.New("client not found") type ClientRepository interface { Create(ctx context.Context, client *domain.Client, secretHash string) error Get(ctx context.Context, clientID uuid.UUID) (*domain.Client, error) + GetWithSecretHash(ctx context.Context, clientID uuid.UUID) (*domain.Client, string, error) List(ctx context.Context) ([]*domain.Client, error) Update(ctx context.Context, client *domain.Client) error UpdateSecret(ctx context.Context, clientID uuid.UUID, secretHash string) error @@ -61,6 +62,23 @@ func (r *clientRepository) Get(ctx context.Context, clientID uuid.UUID) (*domain return r.toDomain(dbClient) } +func (r *clientRepository) GetWithSecretHash(ctx context.Context, clientID uuid.UUID) (*domain.Client, string, error) { + dbClient, err := r.queries.GetClient(ctx, clientID.String()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", ErrClientNotFound + } + return nil, "", err + } + + client, err := r.toDomain(dbClient) + if err != nil { + return nil, "", err + } + + return client, dbClient.ClientSecretHash.String, nil +} + func (r *clientRepository) List(ctx context.Context) ([]*domain.Client, error) { dbClients, err := r.queries.ListClients(ctx) if err != nil { diff --git a/internal/repository/oauth/authcode.go b/internal/repository/oauth/authcode.go index 2887709..d3bb171 100644 --- a/internal/repository/oauth/authcode.go +++ b/internal/repository/oauth/authcode.go @@ -2,14 +2,14 @@ package oauth import ( "context" - "database/sql" "errors" - "strings" + "net/url" "time" "github.com/ory/fosite" - "github.com/traPtitech/portal-oidc/internal/repository/oidc" + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" ) func (s *Storage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error { @@ -18,76 +18,61 @@ func (s *Storage) CreateAuthorizeCodeSession(ctx context.Context, code string, r return errors.New("invalid session type") } - return s.queries.CreateAuthorizationCode(ctx, oidc.CreateAuthorizationCodeParams{ - Code: code, - ClientID: request.GetClient().GetID(), - UserID: sess.GetSubject(), - RedirectUri: request.GetRequestForm().Get("redirect_uri"), - Scopes: strings.Join(request.GetRequestedScopes(), " "), - CodeChallenge: sql.NullString{Valid: false}, - CodeChallengeMethod: sql.NullString{Valid: false}, - Nonce: sql.NullString{ - String: request.GetRequestForm().Get("nonce"), - Valid: request.GetRequestForm().Get("nonce") != "", - }, - ExpiresAt: sess.GetExpiresAt(fosite.AuthorizeCode), + return s.getAuthCodes(ctx).Create(ctx, domain.AuthCode{ + Code: code, + ClientID: request.GetClient().GetID(), + UserID: sess.GetSubject(), + RedirectURI: request.GetRequestForm().Get("redirect_uri"), + Scopes: request.GetRequestedScopes(), + Nonce: request.GetRequestForm().Get("nonce"), + ExpiresAt: sess.GetExpiresAt(fosite.AuthorizeCode), }) } func (s *Storage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error) { - dbCode, err := s.queries.GetAuthorizationCode(ctx, code) + authCode, err := s.getAuthCodes(ctx).Get(ctx, code) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, repository.ErrAuthCodeNotFound) { return nil, fosite.ErrNotFound } return nil, err } - if time.Now().After(dbCode.ExpiresAt) { + if time.Now().After(authCode.ExpiresAt) { return nil, fosite.ErrTokenExpired } - client, err := s.GetClient(ctx, dbCode.ClientID) + client, err := s.GetClient(ctx, authCode.ClientID) if err != nil { return nil, err } - scopes := strings.Split(dbCode.Scopes, " ") - if dbCode.Scopes == "" { - scopes = []string{} - } - - sess := NewSession(dbCode.UserID) - sess.SetExpiresAt(fosite.AuthorizeCode, dbCode.ExpiresAt) + sess := NewSession(authCode.UserID, time.Time{}) + sess.SetExpiresAt(fosite.AuthorizeCode, authCode.ExpiresAt) - form := make(map[string][]string) - form["redirect_uri"] = []string{dbCode.RedirectUri} - if dbCode.CodeChallenge.Valid { - form["code_challenge"] = []string{dbCode.CodeChallenge.String} + form := url.Values{} + form.Set("redirect_uri", authCode.RedirectURI) + if authCode.CodeChallenge != "" { + form.Set("code_challenge", authCode.CodeChallenge) } - if dbCode.CodeChallengeMethod.Valid { - form["code_challenge_method"] = []string{dbCode.CodeChallengeMethod.String} + if authCode.CodeChallengeMethod != "" { + form.Set("code_challenge_method", authCode.CodeChallengeMethod) } - if dbCode.Nonce.Valid { - form["nonce"] = []string{dbCode.Nonce.String} + if authCode.Nonce != "" { + form.Set("nonce", authCode.Nonce) } - req := &fosite.Request{ - ID: code, - RequestedAt: dbCode.CreatedAt, - Client: client, - Form: form, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) + req := newFositeRequest(code, authCode.CreatedAt, client, sess, authCode.Scopes, form) + + if authCode.Used { + return req, fosite.ErrInvalidatedAuthorizeCode } + return req, nil } func (s *Storage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error { - return s.queries.DeleteAuthorizationCode(ctx, code) + return s.getAuthCodes(ctx).MarkUsed(ctx, code) } func (s *Storage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { @@ -102,17 +87,7 @@ func (s *Storage) CreatePKCERequestSession(ctx context.Context, signature string return nil } - return s.queries.UpdateAuthorizationCodePKCE(ctx, oidc.UpdateAuthorizationCodePKCEParams{ - CodeChallenge: sql.NullString{ - String: challenge, - Valid: true, - }, - CodeChallengeMethod: sql.NullString{ - String: method, - Valid: method != "", - }, - Code: signature, - }) + return s.getAuthCodes(ctx).UpdatePKCE(ctx, signature, challenge, method) } func (s *Storage) DeletePKCERequestSession(ctx context.Context, signature string) error { diff --git a/internal/repository/oauth/client.go b/internal/repository/oauth/client.go index aaed517..a3bf9b4 100644 --- a/internal/repository/oauth/client.go +++ b/internal/repository/oauth/client.go @@ -9,7 +9,7 @@ var _ fosite.Client = (*Client)(nil) type Client struct { ID string - Secret []byte + Secret []byte // #nosec G117 -- hashed secret, not plaintext RedirectURIs []string GrantTypes []string ResponseTypes []string diff --git a/internal/repository/oauth/oidc.go b/internal/repository/oauth/oidc.go index 644e1f7..0174d91 100644 --- a/internal/repository/oauth/oidc.go +++ b/internal/repository/oauth/oidc.go @@ -2,30 +2,56 @@ package oauth import ( "context" + "errors" + "net/url" "github.com/ory/fosite" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" ) -func (s *Storage) CreateOpenIDConnectSession(_ context.Context, authorizeCode string, requester fosite.Requester) error { - s.oidcSessionsMutex.Lock() - defer s.oidcSessionsMutex.Unlock() - s.oidcSessions[authorizeCode] = requester - return nil +func (s *Storage) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error { + sess, ok := requester.GetSession().(*Session) + if !ok { + return errors.New("invalid session type") + } + + return s.getOIDCSessions(ctx).Create(ctx, domain.OIDCSession{ + AuthorizeCode: authorizeCode, + ClientID: requester.GetClient().GetID(), + UserID: sess.GetSubject(), + Nonce: requester.GetRequestForm().Get("nonce"), + AuthTime: sess.IDTokenClaims().AuthTime, + Scopes: requester.GetGrantedScopes(), + RequestedAt: requester.GetRequestedAt(), + }) } -func (s *Storage) GetOpenIDConnectSession(_ context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { - s.oidcSessionsMutex.RLock() - defer s.oidcSessionsMutex.RUnlock() - req, ok := s.oidcSessions[authorizeCode] - if !ok { - return nil, fosite.ErrNotFound +func (s *Storage) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) { + oidcSession, err := s.getOIDCSessions(ctx).Get(ctx, authorizeCode) + if err != nil { + if errors.Is(err, repository.ErrOIDCSessionNotFound) { + return nil, fosite.ErrNotFound + } + return nil, err } - return req, nil + + client, err := s.GetClient(ctx, oidcSession.ClientID) + if err != nil { + return nil, err + } + + sess := NewSession(oidcSession.UserID, oidcSession.AuthTime) + + form := url.Values{} + if oidcSession.Nonce != "" { + form.Set("nonce", oidcSession.Nonce) + } + + return newFositeRequest(authorizeCode, oidcSession.RequestedAt, client, sess, oidcSession.Scopes, form), nil } -func (s *Storage) DeleteOpenIDConnectSession(_ context.Context, authorizeCode string) error { - s.oidcSessionsMutex.Lock() - defer s.oidcSessionsMutex.Unlock() - delete(s.oidcSessions, authorizeCode) - return nil +func (s *Storage) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error { + return s.getOIDCSessions(ctx).Delete(ctx, authorizeCode) } diff --git a/internal/repository/oauth/session.go b/internal/repository/oauth/session.go index 6118852..7f1c93c 100644 --- a/internal/repository/oauth/session.go +++ b/internal/repository/oauth/session.go @@ -20,16 +20,16 @@ type Session struct { idTokenHeaders *jwt.Headers } -func NewSession(subject string) *Session { - now := time.Now() +func NewSession(subject string, authTime time.Time) *Session { return &Session{ subject: subject, username: subject, expiresAt: make(map[fosite.TokenType]time.Time), extra: make(map[string]interface{}), idTokenClaims: &jwt.IDTokenClaims{ - Subject: subject, - AuthTime: now, + Subject: subject, + AuthTime: authTime, + RequestedAt: authTime, }, idTokenHeaders: &jwt.Headers{ Extra: make(map[string]interface{}), diff --git a/internal/repository/oauth/storage.go b/internal/repository/oauth/storage.go index 0ce4862..43dbcdb 100644 --- a/internal/repository/oauth/storage.go +++ b/internal/repository/oauth/storage.go @@ -3,53 +3,141 @@ package oauth import ( "context" "database/sql" - "encoding/json" "errors" - "sync" + "net/url" "time" + "github.com/google/uuid" "github.com/ory/fosite" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/handler/pkce" + "github.com/ory/fosite/storage" + "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/repository/oidc" ) -var _ fosite.Storage = (*Storage)(nil) +var ( + _ fosite.Storage = (*Storage)(nil) + _ oauth2.CoreStorage = (*Storage)(nil) + _ oauth2.TokenRevocationStorage = (*Storage)(nil) + _ pkce.PKCERequestStorage = (*Storage)(nil) + _ openid.OpenIDConnectRequestStorage = (*Storage)(nil) + _ storage.Transactional = (*Storage)(nil) +) type Storage struct { - queries *oidc.Queries - oidcSessions map[string]fosite.Requester - oidcSessionsMutex sync.RWMutex + db *sql.DB + baseQueries *oidc.Queries + clients repository.ClientRepository + authCodes repository.AuthCodeRepository + tokens repository.TokenRepository + oidcSessions repository.OIDCSessionRepository } -func NewStorage(queries *oidc.Queries) *Storage { +func NewStorage( + db *sql.DB, + baseQueries *oidc.Queries, + clients repository.ClientRepository, + authCodes repository.AuthCodeRepository, + tokens repository.TokenRepository, + oidcSessions repository.OIDCSessionRepository, +) *Storage { return &Storage{ - queries: queries, - oidcSessions: make(map[string]fosite.Requester), + db: db, + baseQueries: baseQueries, + clients: clients, + authCodes: authCodes, + tokens: tokens, + oidcSessions: oidcSessions, + } +} + +type txKey struct{} + +type txState struct { + tx *sql.Tx + authCodes repository.AuthCodeRepository + tokens repository.TokenRepository + oidcSessions repository.OIDCSessionRepository +} + +func (s *Storage) BeginTX(ctx context.Context) (context.Context, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return ctx, err + } + + txQueries := s.baseQueries.WithTx(tx) + + return context.WithValue(ctx, txKey{}, &txState{ + tx: tx, + authCodes: repository.NewAuthCodeRepository(txQueries), + tokens: repository.NewTokenRepository(txQueries), + oidcSessions: repository.NewOIDCSessionRepository(txQueries), + }), nil +} + +func (s *Storage) Commit(ctx context.Context) error { + state, ok := ctx.Value(txKey{}).(*txState) + if !ok { + return errors.New("no transaction in context") } + return state.tx.Commit() +} + +func (s *Storage) Rollback(ctx context.Context) error { + state, ok := ctx.Value(txKey{}).(*txState) + if !ok { + return errors.New("no transaction in context") + } + return state.tx.Rollback() +} + +func (s *Storage) getAuthCodes(ctx context.Context) repository.AuthCodeRepository { + if state, ok := ctx.Value(txKey{}).(*txState); ok { + return state.authCodes + } + return s.authCodes +} + +func (s *Storage) getTokens(ctx context.Context) repository.TokenRepository { + if state, ok := ctx.Value(txKey{}).(*txState); ok { + return state.tokens + } + return s.tokens +} + +func (s *Storage) getOIDCSessions(ctx context.Context) repository.OIDCSessionRepository { + if state, ok := ctx.Value(txKey{}).(*txState); ok { + return state.oidcSessions + } + return s.oidcSessions } func (s *Storage) GetClient(ctx context.Context, id string) (fosite.Client, error) { - dbClient, err := s.queries.GetClient(ctx, id) + clientID, err := uuid.Parse(id) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, fosite.ErrNotFound - } - return nil, err + return nil, fosite.ErrNotFound } - var redirectURIs []string - if err := json.Unmarshal(dbClient.RedirectUris, &redirectURIs); err != nil { + client, secretHash, err := s.clients.GetWithSecretHash(ctx, clientID) + if err != nil { + if errors.Is(err, repository.ErrClientNotFound) { + return nil, fosite.ErrNotFound + } return nil, err } return &Client{ - ID: dbClient.ClientID, - Secret: []byte(dbClient.ClientSecretHash.String), - RedirectURIs: redirectURIs, + ID: client.ClientID.String(), + Secret: []byte(secretHash), + RedirectURIs: client.RedirectURIs, GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, Scopes: []string{"openid", "profile", "email"}, - Public: dbClient.ClientType == "public", + Public: client.ClientType == "public", }, nil } @@ -60,3 +148,18 @@ func (s *Storage) ClientAssertionJWTValid(ctx context.Context, jti string) error func (s *Storage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error { return nil } + +func newFositeRequest(id string, requestedAt time.Time, client fosite.Client, session *Session, scopes []string, form url.Values) *fosite.Request { + req := &fosite.Request{ + ID: id, + RequestedAt: requestedAt, + Client: client, + Session: session, + Form: form, + } + req.SetRequestedScopes(scopes) + for _, scope := range scopes { + req.GrantScope(scope) + } + return req +} diff --git a/internal/repository/oauth/token.go b/internal/repository/oauth/token.go index fe149b1..380b35e 100644 --- a/internal/repository/oauth/token.go +++ b/internal/repository/oauth/token.go @@ -2,15 +2,14 @@ package oauth import ( "context" - "database/sql" "errors" - "strings" "time" "github.com/google/uuid" "github.com/ory/fosite" - "github.com/traPtitech/portal-oidc/internal/repository/oidc" + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" ) func (s *Storage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error { @@ -19,81 +18,59 @@ func (s *Storage) CreateAccessTokenSession(ctx context.Context, signature string return errors.New("invalid session type") } - tokenID := uuid.New() - return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ - ID: tokenID.String(), + return s.getTokens(ctx).Create(ctx, domain.Token{ + ID: uuid.New().String(), + RequestID: request.GetID(), ClientID: request.GetClient().GetID(), UserID: sess.GetSubject(), AccessToken: signature, - RefreshToken: sql.NullString{ - Valid: false, - }, - Scopes: strings.Join(request.GetGrantedScopes(), " "), - ExpiresAt: sess.GetExpiresAt(fosite.AccessToken), + Scopes: request.GetGrantedScopes(), + ExpiresAt: sess.GetExpiresAt(fosite.AccessToken), }) } func (s *Storage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { - dbToken, err := s.queries.GetTokenByAccessToken(ctx, signature) + token, err := s.getTokens(ctx).GetByAccessToken(ctx, signature) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, repository.ErrTokenNotFound) { return nil, fosite.ErrNotFound } return nil, err } - if time.Now().After(dbToken.ExpiresAt) { + if time.Now().After(token.ExpiresAt) { return nil, fosite.ErrTokenExpired } - client, err := s.GetClient(ctx, dbToken.ClientID) + client, err := s.GetClient(ctx, token.ClientID) if err != nil { return nil, err } - scopes := strings.Split(dbToken.Scopes, " ") - if dbToken.Scopes == "" { - scopes = []string{} - } - - sess := NewSession(dbToken.UserID) - sess.SetExpiresAt(fosite.AccessToken, dbToken.ExpiresAt) + sess := NewSession(token.UserID, time.Time{}) + sess.SetExpiresAt(fosite.AccessToken, token.ExpiresAt) - req := &fosite.Request{ - ID: dbToken.ID, - RequestedAt: dbToken.CreatedAt, - Client: client, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) - } - return req, nil + return newFositeRequest(token.RequestID, token.CreatedAt, client, sess, token.Scopes, nil), nil } func (s *Storage) DeleteAccessTokenSession(ctx context.Context, signature string) error { - return s.queries.DeleteTokenByAccessToken(ctx, signature) + return s.getTokens(ctx).DeleteByAccessToken(ctx, signature) } -func (s *Storage) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error { +func (s *Storage) CreateRefreshTokenSession(ctx context.Context, signature string, _ string, request fosite.Requester) error { sess, ok := request.GetSession().(*Session) if !ok { return errors.New("invalid session type") } - tokenID := uuid.New() - return s.queries.CreateToken(ctx, oidc.CreateTokenParams{ - ID: tokenID.String(), - ClientID: request.GetClient().GetID(), - UserID: sess.GetSubject(), - AccessToken: accessSignature, - RefreshToken: sql.NullString{ - String: signature, - Valid: true, - }, - Scopes: strings.Join(request.GetGrantedScopes(), " "), - ExpiresAt: sess.GetExpiresAt(fosite.RefreshToken), + return s.getTokens(ctx).Create(ctx, domain.Token{ + ID: uuid.New().String(), + RequestID: request.GetID(), + ClientID: request.GetClient().GetID(), + UserID: sess.GetSubject(), + RefreshToken: signature, + Scopes: request.GetGrantedScopes(), + ExpiresAt: sess.GetExpiresAt(fosite.RefreshToken), }) } @@ -102,52 +79,33 @@ func (s *Storage) RotateRefreshToken(ctx context.Context, requestID string, refr } func (s *Storage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error) { - dbToken, err := s.queries.GetTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) + token, err := s.getTokens(ctx).GetByRefreshToken(ctx, signature) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, repository.ErrTokenNotFound) { return nil, fosite.ErrNotFound } return nil, err } - client, err := s.GetClient(ctx, dbToken.ClientID) + client, err := s.GetClient(ctx, token.ClientID) if err != nil { return nil, err } - scopes := strings.Split(dbToken.Scopes, " ") - if dbToken.Scopes == "" { - scopes = []string{} - } - - sess := NewSession(dbToken.UserID) - sess.SetExpiresAt(fosite.RefreshToken, dbToken.ExpiresAt) + sess := NewSession(token.UserID, time.Time{}) + sess.SetExpiresAt(fosite.RefreshToken, token.ExpiresAt) - req := &fosite.Request{ - ID: dbToken.ID, - RequestedAt: dbToken.CreatedAt, - Client: client, - Session: sess, - } - req.SetRequestedScopes(scopes) - for _, scope := range scopes { - req.GrantScope(scope) - } - return req, nil + return newFositeRequest(token.RequestID, token.CreatedAt, client, sess, token.Scopes, nil), nil } func (s *Storage) DeleteRefreshTokenSession(ctx context.Context, signature string) error { - return s.queries.DeleteTokenByRefreshToken(ctx, sql.NullString{String: signature, Valid: true}) + return s.getTokens(ctx).DeleteByRefreshToken(ctx, signature) } func (s *Storage) RevokeRefreshToken(ctx context.Context, requestID string) error { - return nil -} - -func (s *Storage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error { - return s.DeleteRefreshTokenSession(ctx, signature) + return s.getTokens(ctx).DeleteByRequestID(ctx, requestID) } func (s *Storage) RevokeAccessToken(ctx context.Context, requestID string) error { - return nil + return s.getTokens(ctx).DeleteByRequestID(ctx, requestID) } diff --git a/internal/repository/oidc/db.go b/internal/repository/oidc/db.go index 7237be1..be84468 100644 --- a/internal/repository/oidc/db.go +++ b/internal/repository/oidc/db.go @@ -30,6 +30,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createClientStmt, err = db.PrepareContext(ctx, createClient); err != nil { return nil, fmt.Errorf("error preparing query CreateClient: %w", err) } + if q.createOIDCSessionStmt, err = db.PrepareContext(ctx, createOIDCSession); err != nil { + return nil, fmt.Errorf("error preparing query CreateOIDCSession: %w", err) + } if q.createTokenStmt, err = db.PrepareContext(ctx, createToken); err != nil { return nil, fmt.Errorf("error preparing query CreateToken: %w", err) } @@ -39,6 +42,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteAllClientsStmt, err = db.PrepareContext(ctx, deleteAllClients); err != nil { return nil, fmt.Errorf("error preparing query DeleteAllClients: %w", err) } + if q.deleteAllOIDCSessionsStmt, err = db.PrepareContext(ctx, deleteAllOIDCSessions); err != nil { + return nil, fmt.Errorf("error preparing query DeleteAllOIDCSessions: %w", err) + } if q.deleteAllTokensStmt, err = db.PrepareContext(ctx, deleteAllTokens); err != nil { return nil, fmt.Errorf("error preparing query DeleteAllTokens: %w", err) } @@ -54,6 +60,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteExpiredTokensStmt, err = db.PrepareContext(ctx, deleteExpiredTokens); err != nil { return nil, fmt.Errorf("error preparing query DeleteExpiredTokens: %w", err) } + if q.deleteOIDCSessionStmt, err = db.PrepareContext(ctx, deleteOIDCSession); err != nil { + return nil, fmt.Errorf("error preparing query DeleteOIDCSession: %w", err) + } if q.deleteTokenStmt, err = db.PrepareContext(ctx, deleteToken); err != nil { return nil, fmt.Errorf("error preparing query DeleteToken: %w", err) } @@ -63,6 +72,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.deleteTokenByRefreshTokenStmt, err = db.PrepareContext(ctx, deleteTokenByRefreshToken); err != nil { return nil, fmt.Errorf("error preparing query DeleteTokenByRefreshToken: %w", err) } + if q.deleteTokensByRequestIDStmt, err = db.PrepareContext(ctx, deleteTokensByRequestID); err != nil { + return nil, fmt.Errorf("error preparing query DeleteTokensByRequestID: %w", err) + } if q.deleteTokensByUserAndClientStmt, err = db.PrepareContext(ctx, deleteTokensByUserAndClient); err != nil { return nil, fmt.Errorf("error preparing query DeleteTokensByUserAndClient: %w", err) } @@ -72,6 +84,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getClientStmt, err = db.PrepareContext(ctx, getClient); err != nil { return nil, fmt.Errorf("error preparing query GetClient: %w", err) } + if q.getOIDCSessionStmt, err = db.PrepareContext(ctx, getOIDCSession); err != nil { + return nil, fmt.Errorf("error preparing query GetOIDCSession: %w", err) + } if q.getTokenByAccessTokenStmt, err = db.PrepareContext(ctx, getTokenByAccessToken); err != nil { return nil, fmt.Errorf("error preparing query GetTokenByAccessToken: %w", err) } @@ -84,6 +99,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listClientsStmt, err = db.PrepareContext(ctx, listClients); err != nil { return nil, fmt.Errorf("error preparing query ListClients: %w", err) } + if q.markAuthorizationCodeUsedStmt, err = db.PrepareContext(ctx, markAuthorizationCodeUsed); err != nil { + return nil, fmt.Errorf("error preparing query MarkAuthorizationCodeUsed: %w", err) + } if q.updateAuthorizationCodePKCEStmt, err = db.PrepareContext(ctx, updateAuthorizationCodePKCE); err != nil { return nil, fmt.Errorf("error preparing query UpdateAuthorizationCodePKCE: %w", err) } @@ -108,6 +126,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createClientStmt: %w", cerr) } } + if q.createOIDCSessionStmt != nil { + if cerr := q.createOIDCSessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createOIDCSessionStmt: %w", cerr) + } + } if q.createTokenStmt != nil { if cerr := q.createTokenStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createTokenStmt: %w", cerr) @@ -123,6 +146,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteAllClientsStmt: %w", cerr) } } + if q.deleteAllOIDCSessionsStmt != nil { + if cerr := q.deleteAllOIDCSessionsStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteAllOIDCSessionsStmt: %w", cerr) + } + } if q.deleteAllTokensStmt != nil { if cerr := q.deleteAllTokensStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteAllTokensStmt: %w", cerr) @@ -148,6 +176,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteExpiredTokensStmt: %w", cerr) } } + if q.deleteOIDCSessionStmt != nil { + if cerr := q.deleteOIDCSessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteOIDCSessionStmt: %w", cerr) + } + } if q.deleteTokenStmt != nil { if cerr := q.deleteTokenStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteTokenStmt: %w", cerr) @@ -163,6 +196,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing deleteTokenByRefreshTokenStmt: %w", cerr) } } + if q.deleteTokensByRequestIDStmt != nil { + if cerr := q.deleteTokensByRequestIDStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing deleteTokensByRequestIDStmt: %w", cerr) + } + } if q.deleteTokensByUserAndClientStmt != nil { if cerr := q.deleteTokensByUserAndClientStmt.Close(); cerr != nil { err = fmt.Errorf("error closing deleteTokensByUserAndClientStmt: %w", cerr) @@ -178,6 +216,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getClientStmt: %w", cerr) } } + if q.getOIDCSessionStmt != nil { + if cerr := q.getOIDCSessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getOIDCSessionStmt: %w", cerr) + } + } if q.getTokenByAccessTokenStmt != nil { if cerr := q.getTokenByAccessTokenStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getTokenByAccessTokenStmt: %w", cerr) @@ -198,6 +241,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listClientsStmt: %w", cerr) } } + if q.markAuthorizationCodeUsedStmt != nil { + if cerr := q.markAuthorizationCodeUsedStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing markAuthorizationCodeUsedStmt: %w", cerr) + } + } if q.updateAuthorizationCodePKCEStmt != nil { if cerr := q.updateAuthorizationCodePKCEStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateAuthorizationCodePKCEStmt: %w", cerr) @@ -254,24 +302,30 @@ type Queries struct { tx *sql.Tx createAuthorizationCodeStmt *sql.Stmt createClientStmt *sql.Stmt + createOIDCSessionStmt *sql.Stmt createTokenStmt *sql.Stmt deleteAllAuthorizationCodesStmt *sql.Stmt deleteAllClientsStmt *sql.Stmt + deleteAllOIDCSessionsStmt *sql.Stmt deleteAllTokensStmt *sql.Stmt deleteAuthorizationCodeStmt *sql.Stmt deleteClientStmt *sql.Stmt deleteExpiredAuthorizationCodesStmt *sql.Stmt deleteExpiredTokensStmt *sql.Stmt + deleteOIDCSessionStmt *sql.Stmt deleteTokenStmt *sql.Stmt deleteTokenByAccessTokenStmt *sql.Stmt deleteTokenByRefreshTokenStmt *sql.Stmt + deleteTokensByRequestIDStmt *sql.Stmt deleteTokensByUserAndClientStmt *sql.Stmt getAuthorizationCodeStmt *sql.Stmt getClientStmt *sql.Stmt + getOIDCSessionStmt *sql.Stmt getTokenByAccessTokenStmt *sql.Stmt getTokenByIDStmt *sql.Stmt getTokenByRefreshTokenStmt *sql.Stmt listClientsStmt *sql.Stmt + markAuthorizationCodeUsedStmt *sql.Stmt updateAuthorizationCodePKCEStmt *sql.Stmt updateClientStmt *sql.Stmt updateClientSecretStmt *sql.Stmt @@ -283,24 +337,30 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { tx: tx, createAuthorizationCodeStmt: q.createAuthorizationCodeStmt, createClientStmt: q.createClientStmt, + createOIDCSessionStmt: q.createOIDCSessionStmt, createTokenStmt: q.createTokenStmt, deleteAllAuthorizationCodesStmt: q.deleteAllAuthorizationCodesStmt, deleteAllClientsStmt: q.deleteAllClientsStmt, + deleteAllOIDCSessionsStmt: q.deleteAllOIDCSessionsStmt, deleteAllTokensStmt: q.deleteAllTokensStmt, deleteAuthorizationCodeStmt: q.deleteAuthorizationCodeStmt, deleteClientStmt: q.deleteClientStmt, deleteExpiredAuthorizationCodesStmt: q.deleteExpiredAuthorizationCodesStmt, deleteExpiredTokensStmt: q.deleteExpiredTokensStmt, + deleteOIDCSessionStmt: q.deleteOIDCSessionStmt, deleteTokenStmt: q.deleteTokenStmt, deleteTokenByAccessTokenStmt: q.deleteTokenByAccessTokenStmt, deleteTokenByRefreshTokenStmt: q.deleteTokenByRefreshTokenStmt, + deleteTokensByRequestIDStmt: q.deleteTokensByRequestIDStmt, deleteTokensByUserAndClientStmt: q.deleteTokensByUserAndClientStmt, getAuthorizationCodeStmt: q.getAuthorizationCodeStmt, getClientStmt: q.getClientStmt, + getOIDCSessionStmt: q.getOIDCSessionStmt, getTokenByAccessTokenStmt: q.getTokenByAccessTokenStmt, getTokenByIDStmt: q.getTokenByIDStmt, getTokenByRefreshTokenStmt: q.getTokenByRefreshTokenStmt, listClientsStmt: q.listClientsStmt, + markAuthorizationCodeUsedStmt: q.markAuthorizationCodeUsedStmt, updateAuthorizationCodePKCEStmt: q.updateAuthorizationCodePKCEStmt, updateClientStmt: q.updateClientStmt, updateClientSecretStmt: q.updateClientSecretStmt, diff --git a/internal/repository/oidc/models.go b/internal/repository/oidc/models.go index fa82d76..e12a274 100644 --- a/internal/repository/oidc/models.go +++ b/internal/repository/oidc/models.go @@ -19,6 +19,7 @@ type AuthorizationCode struct { CodeChallenge sql.NullString `json:"code_challenge"` CodeChallengeMethod sql.NullString `json:"code_challenge_method"` Nonce sql.NullString `json:"nonce"` + Used bool `json:"used"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` } @@ -33,8 +34,20 @@ type Client struct { UpdatedAt time.Time `json:"updated_at"` } +type OidcSession struct { + AuthorizeCode string `json:"authorize_code"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + Scopes string `json:"scopes"` + Nonce sql.NullString `json:"nonce"` + AuthTime time.Time `json:"auth_time"` + RequestedAt time.Time `json:"requested_at"` + CreatedAt time.Time `json:"created_at"` +} + type Token struct { ID string `json:"id"` + RequestID string `json:"request_id"` ClientID string `json:"client_id"` UserID string `json:"user_id"` AccessToken string `json:"access_token"` diff --git a/internal/repository/oidc/oidc.sql.go b/internal/repository/oidc/oidc.sql.go index 7c8c921..5ced6c4 100644 --- a/internal/repository/oidc/oidc.sql.go +++ b/internal/repository/oidc/oidc.sql.go @@ -86,21 +86,60 @@ func (q *Queries) CreateClient(ctx context.Context, arg CreateClientParams) erro return err } +const createOIDCSession = `-- name: CreateOIDCSession :exec + +INSERT INTO oidc_sessions ( + authorize_code, + client_id, + user_id, + scopes, + nonce, + auth_time, + requested_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +` + +type CreateOIDCSessionParams struct { + AuthorizeCode string `json:"authorize_code"` + ClientID string `json:"client_id"` + UserID string `json:"user_id"` + Scopes string `json:"scopes"` + Nonce sql.NullString `json:"nonce"` + AuthTime time.Time `json:"auth_time"` + RequestedAt time.Time `json:"requested_at"` +} + +// OIDC Session queries +func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) error { + _, err := q.exec(ctx, q.createOIDCSessionStmt, createOIDCSession, + arg.AuthorizeCode, + arg.ClientID, + arg.UserID, + arg.Scopes, + arg.Nonce, + arg.AuthTime, + arg.RequestedAt, + ) + return err +} + const createToken = `-- name: CreateToken :exec INSERT INTO tokens ( id, + request_id, client_id, user_id, access_token, refresh_token, scopes, expires_at -) VALUES (?, ?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ` type CreateTokenParams struct { ID string `json:"id"` + RequestID string `json:"request_id"` ClientID string `json:"client_id"` UserID string `json:"user_id"` AccessToken string `json:"access_token"` @@ -113,6 +152,7 @@ type CreateTokenParams struct { func (q *Queries) CreateToken(ctx context.Context, arg CreateTokenParams) error { _, err := q.exec(ctx, q.createTokenStmt, createToken, arg.ID, + arg.RequestID, arg.ClientID, arg.UserID, arg.AccessToken, @@ -141,6 +181,15 @@ func (q *Queries) DeleteAllClients(ctx context.Context) error { return err } +const deleteAllOIDCSessions = `-- name: DeleteAllOIDCSessions :exec +DELETE FROM oidc_sessions +` + +func (q *Queries) DeleteAllOIDCSessions(ctx context.Context) error { + _, err := q.exec(ctx, q.deleteAllOIDCSessionsStmt, deleteAllOIDCSessions) + return err +} + const deleteAllTokens = `-- name: DeleteAllTokens :exec DELETE FROM tokens ` @@ -186,6 +235,15 @@ func (q *Queries) DeleteExpiredTokens(ctx context.Context) error { return err } +const deleteOIDCSession = `-- name: DeleteOIDCSession :exec +DELETE FROM oidc_sessions WHERE authorize_code = ? +` + +func (q *Queries) DeleteOIDCSession(ctx context.Context, authorizeCode string) error { + _, err := q.exec(ctx, q.deleteOIDCSessionStmt, deleteOIDCSession, authorizeCode) + return err +} + const deleteToken = `-- name: DeleteToken :exec DELETE FROM tokens WHERE id = ? ` @@ -213,6 +271,15 @@ func (q *Queries) DeleteTokenByRefreshToken(ctx context.Context, refreshToken sq return err } +const deleteTokensByRequestID = `-- name: DeleteTokensByRequestID :exec +DELETE FROM tokens WHERE request_id = ? +` + +func (q *Queries) DeleteTokensByRequestID(ctx context.Context, requestID string) error { + _, err := q.exec(ctx, q.deleteTokensByRequestIDStmt, deleteTokensByRequestID, requestID) + return err +} + const deleteTokensByUserAndClient = `-- name: DeleteTokensByUserAndClient :exec DELETE FROM tokens WHERE user_id = ? AND client_id = ? ` @@ -228,7 +295,7 @@ func (q *Queries) DeleteTokensByUserAndClient(ctx context.Context, arg DeleteTok } const getAuthorizationCode = `-- name: GetAuthorizationCode :one -SELECT code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, nonce, expires_at, created_at FROM authorization_codes WHERE code = ? +SELECT code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, nonce, used, expires_at, created_at FROM authorization_codes WHERE code = ? ` func (q *Queries) GetAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) { @@ -243,6 +310,7 @@ func (q *Queries) GetAuthorizationCode(ctx context.Context, code string) (Author &i.CodeChallenge, &i.CodeChallengeMethod, &i.Nonce, + &i.Used, &i.ExpiresAt, &i.CreatedAt, ) @@ -268,8 +336,28 @@ func (q *Queries) GetClient(ctx context.Context, clientID string) (Client, error return i, err } +const getOIDCSession = `-- name: GetOIDCSession :one +SELECT authorize_code, client_id, user_id, scopes, nonce, auth_time, requested_at, created_at FROM oidc_sessions WHERE authorize_code = ? +` + +func (q *Queries) GetOIDCSession(ctx context.Context, authorizeCode string) (OidcSession, error) { + row := q.queryRow(ctx, q.getOIDCSessionStmt, getOIDCSession, authorizeCode) + var i OidcSession + err := row.Scan( + &i.AuthorizeCode, + &i.ClientID, + &i.UserID, + &i.Scopes, + &i.Nonce, + &i.AuthTime, + &i.RequestedAt, + &i.CreatedAt, + ) + return i, err +} + const getTokenByAccessToken = `-- name: GetTokenByAccessToken :one -SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE access_token = ? +SELECT id, request_id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE access_token = ? ` func (q *Queries) GetTokenByAccessToken(ctx context.Context, accessToken string) (Token, error) { @@ -277,6 +365,7 @@ func (q *Queries) GetTokenByAccessToken(ctx context.Context, accessToken string) var i Token err := row.Scan( &i.ID, + &i.RequestID, &i.ClientID, &i.UserID, &i.AccessToken, @@ -289,7 +378,7 @@ func (q *Queries) GetTokenByAccessToken(ctx context.Context, accessToken string) } const getTokenByID = `-- name: GetTokenByID :one -SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE id = ? +SELECT id, request_id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE id = ? ` func (q *Queries) GetTokenByID(ctx context.Context, id string) (Token, error) { @@ -297,6 +386,7 @@ func (q *Queries) GetTokenByID(ctx context.Context, id string) (Token, error) { var i Token err := row.Scan( &i.ID, + &i.RequestID, &i.ClientID, &i.UserID, &i.AccessToken, @@ -309,7 +399,7 @@ func (q *Queries) GetTokenByID(ctx context.Context, id string) (Token, error) { } const getTokenByRefreshToken = `-- name: GetTokenByRefreshToken :one -SELECT id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE refresh_token = ? +SELECT id, request_id, client_id, user_id, access_token, refresh_token, scopes, expires_at, created_at FROM tokens WHERE refresh_token = ? ` func (q *Queries) GetTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) (Token, error) { @@ -317,6 +407,7 @@ func (q *Queries) GetTokenByRefreshToken(ctx context.Context, refreshToken sql.N var i Token err := row.Scan( &i.ID, + &i.RequestID, &i.ClientID, &i.UserID, &i.AccessToken, @@ -363,6 +454,15 @@ func (q *Queries) ListClients(ctx context.Context) ([]Client, error) { return items, nil } +const markAuthorizationCodeUsed = `-- name: MarkAuthorizationCodeUsed :exec +UPDATE authorization_codes SET used = TRUE WHERE code = ? +` + +func (q *Queries) MarkAuthorizationCodeUsed(ctx context.Context, code string) error { + _, err := q.exec(ctx, q.markAuthorizationCodeUsedStmt, markAuthorizationCodeUsed, code) + return err +} + const updateAuthorizationCodePKCE = `-- name: UpdateAuthorizationCodePKCE :exec UPDATE authorization_codes SET code_challenge = ?, diff --git a/internal/repository/oidc/querier.go b/internal/repository/oidc/querier.go index 33b1031..83fb8d7 100644 --- a/internal/repository/oidc/querier.go +++ b/internal/repository/oidc/querier.go @@ -14,25 +14,32 @@ type Querier interface { CreateAuthorizationCode(ctx context.Context, arg CreateAuthorizationCodeParams) error // Client queries CreateClient(ctx context.Context, arg CreateClientParams) error + // OIDC Session queries + CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) error // Token queries CreateToken(ctx context.Context, arg CreateTokenParams) error DeleteAllAuthorizationCodes(ctx context.Context) error DeleteAllClients(ctx context.Context) error + DeleteAllOIDCSessions(ctx context.Context) error DeleteAllTokens(ctx context.Context) error DeleteAuthorizationCode(ctx context.Context, code string) error DeleteClient(ctx context.Context, clientID string) error DeleteExpiredAuthorizationCodes(ctx context.Context) error DeleteExpiredTokens(ctx context.Context) error + DeleteOIDCSession(ctx context.Context, authorizeCode string) error DeleteToken(ctx context.Context, id string) error DeleteTokenByAccessToken(ctx context.Context, accessToken string) error DeleteTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) error + DeleteTokensByRequestID(ctx context.Context, requestID string) error DeleteTokensByUserAndClient(ctx context.Context, arg DeleteTokensByUserAndClientParams) error GetAuthorizationCode(ctx context.Context, code string) (AuthorizationCode, error) GetClient(ctx context.Context, clientID string) (Client, error) + GetOIDCSession(ctx context.Context, authorizeCode string) (OidcSession, error) GetTokenByAccessToken(ctx context.Context, accessToken string) (Token, error) GetTokenByID(ctx context.Context, id string) (Token, error) GetTokenByRefreshToken(ctx context.Context, refreshToken sql.NullString) (Token, error) ListClients(ctx context.Context) ([]Client, error) + MarkAuthorizationCodeUsed(ctx context.Context, code string) error UpdateAuthorizationCodePKCE(ctx context.Context, arg UpdateAuthorizationCodePKCEParams) error UpdateClient(ctx context.Context, arg UpdateClientParams) error UpdateClientSecret(ctx context.Context, arg UpdateClientSecretParams) error diff --git a/internal/repository/oidcsession.go b/internal/repository/oidcsession.go new file mode 100644 index 0000000..1531a4d --- /dev/null +++ b/internal/repository/oidcsession.go @@ -0,0 +1,71 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +var ErrOIDCSessionNotFound = errors.New("OIDC session not found") + +type OIDCSessionRepository interface { + Create(ctx context.Context, session domain.OIDCSession) error + Get(ctx context.Context, authorizeCode string) (domain.OIDCSession, error) + Delete(ctx context.Context, authorizeCode string) error +} + +type oidcSessionRepository struct { + queries *oidc.Queries +} + +func NewOIDCSessionRepository(queries *oidc.Queries) OIDCSessionRepository { + return &oidcSessionRepository{queries: queries} +} + +func (r *oidcSessionRepository) Create(ctx context.Context, session domain.OIDCSession) error { + return r.queries.CreateOIDCSession(ctx, oidc.CreateOIDCSessionParams{ + AuthorizeCode: session.AuthorizeCode, + ClientID: session.ClientID, + UserID: session.UserID, + Scopes: strings.Join(session.Scopes, " "), + Nonce: sql.NullString{ + String: session.Nonce, + Valid: session.Nonce != "", + }, + AuthTime: session.AuthTime, + RequestedAt: session.RequestedAt, + }) +} + +func (r *oidcSessionRepository) Get(ctx context.Context, authorizeCode string) (domain.OIDCSession, error) { + dbSession, err := r.queries.GetOIDCSession(ctx, authorizeCode) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.OIDCSession{}, ErrOIDCSessionNotFound + } + return domain.OIDCSession{}, err + } + + return toDomainOIDCSession(dbSession), nil +} + +func (r *oidcSessionRepository) Delete(ctx context.Context, authorizeCode string) error { + return r.queries.DeleteOIDCSession(ctx, authorizeCode) +} + +func toDomainOIDCSession(db oidc.OidcSession) domain.OIDCSession { + return domain.OIDCSession{ + AuthorizeCode: db.AuthorizeCode, + ClientID: db.ClientID, + UserID: db.UserID, + Nonce: db.Nonce.String, + AuthTime: db.AuthTime, + Scopes: splitScopes(db.Scopes), + RequestedAt: db.RequestedAt, + CreatedAt: db.CreatedAt, + } +} diff --git a/internal/repository/token.go b/internal/repository/token.go new file mode 100644 index 0000000..0879d72 --- /dev/null +++ b/internal/repository/token.go @@ -0,0 +1,120 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository/oidc" +) + +var ErrTokenNotFound = errors.New("token not found") + +type TokenRepository interface { + Create(ctx context.Context, token domain.Token) error + GetByAccessToken(ctx context.Context, accessToken string) (domain.Token, error) + GetByRefreshToken(ctx context.Context, refreshToken string) (domain.Token, error) + GetByID(ctx context.Context, id string) (domain.Token, error) + DeleteByAccessToken(ctx context.Context, accessToken string) error + DeleteByRefreshToken(ctx context.Context, refreshToken string) error + DeleteByID(ctx context.Context, id string) error + DeleteByRequestID(ctx context.Context, requestID string) error +} + +type tokenRepository struct { + queries *oidc.Queries +} + +func NewTokenRepository(queries *oidc.Queries) TokenRepository { + return &tokenRepository{queries: queries} +} + +func (r *tokenRepository) Create(ctx context.Context, token domain.Token) error { + return r.queries.CreateToken(ctx, oidc.CreateTokenParams{ + ID: token.ID, + RequestID: token.RequestID, + ClientID: token.ClientID, + UserID: token.UserID, + AccessToken: token.AccessToken, + RefreshToken: sql.NullString{ + String: token.RefreshToken, + Valid: token.RefreshToken != "", + }, + Scopes: strings.Join(token.Scopes, " "), + ExpiresAt: token.ExpiresAt, + }) +} + +func (r *tokenRepository) GetByAccessToken(ctx context.Context, accessToken string) (domain.Token, error) { + dbToken, err := r.queries.GetTokenByAccessToken(ctx, accessToken) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Token{}, ErrTokenNotFound + } + return domain.Token{}, err + } + + return toDomainToken(dbToken), nil +} + +func (r *tokenRepository) GetByRefreshToken(ctx context.Context, refreshToken string) (domain.Token, error) { + dbToken, err := r.queries.GetTokenByRefreshToken(ctx, sql.NullString{ + String: refreshToken, + Valid: true, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Token{}, ErrTokenNotFound + } + return domain.Token{}, err + } + + return toDomainToken(dbToken), nil +} + +func (r *tokenRepository) GetByID(ctx context.Context, id string) (domain.Token, error) { + dbToken, err := r.queries.GetTokenByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.Token{}, ErrTokenNotFound + } + return domain.Token{}, err + } + + return toDomainToken(dbToken), nil +} + +func (r *tokenRepository) DeleteByAccessToken(ctx context.Context, accessToken string) error { + return r.queries.DeleteTokenByAccessToken(ctx, accessToken) +} + +func (r *tokenRepository) DeleteByRefreshToken(ctx context.Context, refreshToken string) error { + return r.queries.DeleteTokenByRefreshToken(ctx, sql.NullString{ + String: refreshToken, + Valid: true, + }) +} + +func (r *tokenRepository) DeleteByID(ctx context.Context, id string) error { + return r.queries.DeleteToken(ctx, id) +} + +func (r *tokenRepository) DeleteByRequestID(ctx context.Context, requestID string) error { + return r.queries.DeleteTokensByRequestID(ctx, requestID) +} + +func toDomainToken(db oidc.Token) domain.Token { + return domain.Token{ + ID: db.ID, + RequestID: db.RequestID, + ClientID: db.ClientID, + UserID: db.UserID, + AccessToken: db.AccessToken, + RefreshToken: db.RefreshToken.String, + Scopes: splitScopes(db.Scopes), + ExpiresAt: db.ExpiresAt, + CreatedAt: db.CreatedAt, + } +} diff --git a/internal/router/v1/oauth.go b/internal/router/v1/oauth.go index f80fede..0f420ce 100644 --- a/internal/router/v1/oauth.go +++ b/internal/router/v1/oauth.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/go-jose/go-jose/v4" "github.com/labstack/echo/v4" @@ -38,7 +39,7 @@ func (h *Handler) Authorize(ctx echo.Context, params gen.AuthorizeParams) error return nil } - session := oauth.NewSession(userID) + session := oauth.NewSession(userID, time.Now()) for _, scope := range ar.GetRequestedScopes() { ar.GrantScope(scope) } @@ -58,7 +59,7 @@ func (h *Handler) Token(ctx echo.Context) error { rw := ctx.Response() req := ctx.Request() - session := oauth.NewSession("") + session := oauth.NewSession("", time.Time{}) accessRequest, err := h.oauth2.NewAccessRequest(c, req, session) if err != nil { h.oauth2.WriteAccessError(c, rw, accessRequest, err) @@ -116,7 +117,7 @@ func (h *Handler) extractBearerToken(ctx echo.Context) (string, error) { func (h *Handler) handleUserInfo(ctx echo.Context, token string) error { c := ctx.Request().Context() - _, ar, err := h.oauth2.IntrospectToken(c, token, fosite.AccessToken, oauth.NewSession("")) + _, ar, err := h.oauth2.IntrospectToken(c, token, fosite.AccessToken, oauth.NewSession("", time.Time{})) if err != nil { return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidGrant}) } From 9b44979cd3d1028a83855c2776e1de6caabf72a2 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:07:48 +0900 Subject: [PATCH 17/26] feat(auth): add user usecase and OIDC prompt/max_age conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract user authentication logic into UserUseCase with GetByID - Simplify UserRepository to pure data access layer - Handle prompt=none (return login_required without redirect) - Handle prompt=login (force re-authentication with loop prevention) - Handle max_age parameter (re-authenticate if auth_time too old) - Store auth_time in login cookie session for OIDC conformance - Add return_url sanitization to prevent open redirects - Harden session cookie with Secure flag and SameSite=Lax - Return scope-based UserInfo claims (profile scope → name) - Add POST method for /oauth2/authorize endpoint (OIDC Core 3.1.2.1) Assisted-by: Claude Code (model: claude-opus-4-6) --- cmd/serve.go | 6 +- internal/repository/user.go | 108 ++++++--------------------- internal/router/v1/auth.go | 55 +++++++++----- internal/router/v1/client_test.go | 11 ++- internal/router/v1/handler.go | 13 ++-- internal/router/v1/oauth.go | 103 ++++++++++++++++++++++--- internal/usecase/user.go | 120 ++++++++++++++++++++++++++++++ 7 files changed, 292 insertions(+), 124 deletions(-) create mode 100644 internal/usecase/user.go diff --git a/cmd/serve.go b/cmd/serve.go index b4afaca..9234662 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -54,10 +54,11 @@ func newServer(cfg Config) (http.Handler, error) { Secret: []byte(cfg.OAuth.Secret), }, privateKey) + userUseCase := usecase.NewUserUseCase(repository.NewUserRepository(portalQueries)) handler := v1.NewHandler( usecase.NewClientUseCase(clientRepo), oauth2Provider, - repository.NewUserRepository(portalQueries), + userUseCase, v1.OAuthConfig{ Issuer: cfg.Host, SessionSecret: []byte(cfg.OAuth.Secret), @@ -75,6 +76,9 @@ func newServer(cfg Config) (http.Handler, error) { AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, })) gen.RegisterHandlers(e, handler) + e.POST("/oauth2/authorize", func(c echo.Context) error { + return handler.Authorize(c, gen.AuthorizeParams{}) + }) e.GET("/login", handler.GetLogin) e.POST("/login", handler.PostLogin) e.GET("/logout", handler.Logout) diff --git a/internal/repository/user.go b/internal/repository/user.go index 459407d..96add14 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,32 +2,19 @@ package repository import ( "context" - "crypto/sha512" - "crypto/subtle" "database/sql" - "encoding/base64" "errors" - "strings" - - "golang.org/x/crypto/pbkdf2" + "github.com/traPtitech/portal-oidc/internal/domain" "github.com/traPtitech/portal-oidc/internal/repository/portal" ) -var ( - ErrUserNotFound = errors.New("user not found") - ErrInvalidPassword = errors.New("invalid password") - ErrUserNotActive = errors.New("user is not active") -) - -type User struct { - ID string - TrapID string -} +var ErrUserNotFound = errors.New("user not found") type UserRepository interface { - Authenticate(ctx context.Context, trapID, password string) (*User, error) - GetByID(ctx context.Context, id string) (*User, error) + GetByID(ctx context.Context, id string) (*domain.User, error) + GetByTrapID(ctx context.Context, trapID string) (*domain.UserWithPassword, error) + ListStatuses(ctx context.Context, userID string) ([]string, error) } type userRepository struct { @@ -38,8 +25,8 @@ func NewUserRepository(queries *portal.Queries) UserRepository { return &userRepository{queries: queries} } -func (r *userRepository) Authenticate(ctx context.Context, trapID, password string) (*User, error) { - user, err := r.queries.GetUserByTrapID(ctx, trapID) +func (r *userRepository) GetByID(ctx context.Context, id string) (*domain.User, error) { + user, err := r.queries.GetUserByID(ctx, id) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound @@ -47,35 +34,14 @@ func (r *userRepository) Authenticate(ctx context.Context, trapID, password stri return nil, err } - if !verifyPBKDF2Password(password, user.PasswordHash) { - return nil, ErrInvalidPassword - } - - statuses, err := r.queries.ListUserStatuses(ctx, user.ID) - if err != nil { - return nil, err - } - - isActive := false - for _, status := range statuses { - if status.Status == "active" { - isActive = true - break - } - } - - if !isActive && len(statuses) > 0 { - return nil, ErrUserNotActive - } - - return &User{ + return &domain.User{ ID: user.ID, TrapID: user.TrapID, }, nil } -func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) { - user, err := r.queries.GetUserByID(ctx, id) +func (r *userRepository) GetByTrapID(ctx context.Context, trapID string) (*domain.UserWithPassword, error) { + user, err := r.queries.GetUserByTrapID(ctx, trapID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound @@ -83,52 +49,24 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) return nil, err } - return &User{ - ID: user.ID, - TrapID: user.TrapID, + return &domain.UserWithPassword{ + User: domain.User{ + ID: user.ID, + TrapID: user.TrapID, + }, + PasswordHash: user.PasswordHash, }, nil } -// verifyPBKDF2Password verifies a password against a PBKDF2-SHA512 hash. -// Expected format: "pbkdf2_sha512$iterations$salt$hash" (base64 encoded) -func verifyPBKDF2Password(password, storedHash string) bool { - parts := strings.Split(storedHash, "$") - if len(parts) != 4 { - return false - } - - algorithm := parts[0] - if algorithm != "pbkdf2_sha512" { - return false - } - - iterations, err := parseIterations(parts[1]) - if err != nil { - return false - } - - salt, err := base64.StdEncoding.DecodeString(parts[2]) +func (r *userRepository) ListStatuses(ctx context.Context, userID string) ([]string, error) { + statuses, err := r.queries.ListUserStatuses(ctx, userID) if err != nil { - return false - } - - expectedHash, err := base64.StdEncoding.DecodeString(parts[3]) - if err != nil { - return false + return nil, err } - computedHash := pbkdf2.Key([]byte(password), salt, iterations, len(expectedHash), sha512.New) - - return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1 -} - -func parseIterations(s string) (int, error) { - var n int - for _, c := range s { - if c < '0' || c > '9' { - return 0, errors.New("invalid iterations") - } - n = n*10 + int(c-'0') + result := make([]string, len(statuses)) + for i, s := range statuses { + result[i] = s.Status } - return n, nil + return result, nil } diff --git a/internal/router/v1/auth.go b/internal/router/v1/auth.go index 5574c68..3769cf8 100644 --- a/internal/router/v1/auth.go +++ b/internal/router/v1/auth.go @@ -4,19 +4,19 @@ import ( "errors" "html" "net/http" + "net/url" + "strings" + "time" "github.com/labstack/echo/v4" - "github.com/traPtitech/portal-oidc/internal/repository" + "github.com/traPtitech/portal-oidc/internal/usecase" ) const sessionName = "oidc_session" func (h *Handler) GetLogin(ctx echo.Context) error { - returnURL := ctx.QueryParam("return_url") - if returnURL == "" { - returnURL = "/" - } + returnURL := sanitizeReturnURL(ctx.QueryParam("return_url")) devNote := "" if h.config.Environment != "production" { @@ -83,15 +83,13 @@ func (h *Handler) PostLogin(ctx echo.Context) error { session.Values["user_id"] = userID session.Values["authenticated"] = true + session.Values["auth_time"] = time.Now().Unix() if err := session.Save(ctx.Request(), ctx.Response()); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to save session") } - if returnURL == "" { - returnURL = "/" - } - return ctx.Redirect(http.StatusFound, returnURL) + return ctx.Redirect(http.StatusFound, sanitizeReturnURL(returnURL)) } func (h *Handler) authenticateTestUser(username, password string) (string, error) { @@ -102,11 +100,11 @@ func (h *Handler) authenticateTestUser(username, password string) (string, error } func (h *Handler) authenticatePortalUser(ctx echo.Context, trapID, password string) (string, error) { - user, err := h.userRepo.Authenticate(ctx.Request().Context(), trapID, password) + user, err := h.userUseCase.Authenticate(ctx.Request().Context(), trapID, password) if err != nil { - if errors.Is(err, repository.ErrUserNotFound) || - errors.Is(err, repository.ErrInvalidPassword) || - errors.Is(err, repository.ErrUserNotActive) { + if errors.Is(err, usecase.ErrUserNotFound) || + errors.Is(err, usecase.ErrInvalidPassword) || + errors.Is(err, usecase.ErrUserNotActive) { return "", errors.New("authentication failed") } return "", err @@ -132,21 +130,42 @@ func (h *Handler) Logout(ctx echo.Context) error { return ctx.Redirect(http.StatusFound, "/") } -func (h *Handler) getAuthenticatedUser(ctx echo.Context) string { +func sanitizeReturnURL(raw string) string { + if raw == "" { + return "/" + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host != "" || strings.HasPrefix(raw, "//") { + return "/" + } + return parsed.RequestURI() +} + +type authInfo struct { + UserID string + AuthTime time.Time +} + +func (h *Handler) getAuthInfo(ctx echo.Context) (authInfo, bool) { session, err := h.sessions.Get(ctx.Request(), sessionName) if err != nil { - return "" + return authInfo{}, false } authenticated, ok := session.Values["authenticated"].(bool) if !ok || !authenticated { - return "" + return authInfo{}, false } userID, ok := session.Values["user_id"].(string) if !ok { - return "" + return authInfo{}, false + } + + at := time.Now() + if authTimeSec, ok := session.Values["auth_time"].(int64); ok { + at = time.Unix(authTimeSec, 0) } - return userID + return authInfo{UserID: userID, AuthTime: at}, true } diff --git a/internal/router/v1/client_test.go b/internal/router/v1/client_test.go index fced327..0ca0d2d 100644 --- a/internal/router/v1/client_test.go +++ b/internal/router/v1/client_test.go @@ -123,8 +123,15 @@ func setupTestHandler(t *testing.T) (*Handler, func()) { clientRepo := repository.NewClientRepository(queries) clientUseCase := usecase.NewClientUseCase(clientRepo) - oauthStorage := oauth.NewStorage(queries) - fositeConfig := &fosite.Config{ + oauthStorage := oauth.NewStorage( + testDB, + queries, + clientRepo, + repository.NewAuthCodeRepository(queries), + repository.NewTokenRepository(queries), + repository.NewOIDCSessionRepository(queries), + ) + fositeConfig := &fosite.Config{ //nolint:gosec // test credentials AccessTokenLifespan: time.Hour, RefreshTokenLifespan: 30 * 24 * time.Hour, AuthorizeCodeLifespan: 5 * time.Minute, diff --git a/internal/router/v1/handler.go b/internal/router/v1/handler.go index 8c692e6..c973eda 100644 --- a/internal/router/v1/handler.go +++ b/internal/router/v1/handler.go @@ -3,25 +3,25 @@ package v1 import ( "crypto/rsa" "net/http" + "strings" "github.com/gorilla/sessions" "github.com/ory/fosite" - "github.com/traPtitech/portal-oidc/internal/repository" "github.com/traPtitech/portal-oidc/internal/usecase" ) type Handler struct { clientUseCase usecase.ClientUseCase oauth2 fosite.OAuth2Provider - userRepo repository.UserRepository + userUseCase usecase.UserUseCase sessions *sessions.CookieStore config OAuthConfig } type OAuthConfig struct { Issuer string - SessionSecret []byte + SessionSecret []byte // #nosec G117 -- internal config, not serialized PrivateKey *rsa.PrivateKey Environment string TestUserID string @@ -30,21 +30,22 @@ type OAuthConfig struct { func NewHandler( clientUseCase usecase.ClientUseCase, oauth2 fosite.OAuth2Provider, - userRepo repository.UserRepository, + userUseCase usecase.UserUseCase, config OAuthConfig, ) *Handler { store := sessions.NewCookieStore(config.SessionSecret) store.Options = &sessions.Options{ Path: "/", - MaxAge: 3600, + MaxAge: 600, HttpOnly: true, + Secure: strings.HasPrefix(config.Issuer, "https://"), SameSite: http.SameSiteLaxMode, } return &Handler{ clientUseCase: clientUseCase, oauth2: oauth2, - userRepo: userRepo, + userUseCase: userUseCase, sessions: store, config: config, } diff --git a/internal/router/v1/oauth.go b/internal/router/v1/oauth.go index 0f420ce..24f2389 100644 --- a/internal/router/v1/oauth.go +++ b/internal/router/v1/oauth.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/url" + "strconv" "strings" "time" @@ -22,24 +23,49 @@ func (h *Handler) Authorize(ctx echo.Context, params gen.AuthorizeParams) error rw := ctx.Response() req := ctx.Request() - var userID string + ar, err := h.oauth2.NewAuthorizeRequest(c, req) + if err != nil { + h.oauth2.WriteAuthorizeError(c, rw, ar, err) + return nil + } + + prompt := ar.GetRequestForm().Get("prompt") + returnURL := req.URL.String() + if h.config.Environment != "production" { - userID = h.config.TestUserID - } else { - userID = h.getAuthenticatedUser(ctx) - if userID == "" { - returnURL := req.URL.String() + return h.completeAuthorize(ctx, ar, h.config.TestUserID, time.Now()) + } + + info, authenticated := h.getAuthInfo(ctx) + + switch prompt { + case "none": + if !authenticated { + h.oauth2.WriteAuthorizeError(c, rw, ar, fosite.ErrLoginRequired) + return nil + } + case "login": + if !authenticated || !h.isReauthCompleted(ctx, info.AuthTime) { + return h.redirectToLogin(ctx, returnURL) + } + default: + if !authenticated { return ctx.Redirect(http.StatusFound, "/login?return_url="+url.QueryEscape(returnURL)) } } - ar, err := h.oauth2.NewAuthorizeRequest(c, req) - if err != nil { - h.oauth2.WriteAuthorizeError(c, rw, ar, err) - return nil + if h.isMaxAgeExpired(ar, info.AuthTime) && !h.isReauthCompleted(ctx, info.AuthTime) { + return h.redirectToLogin(ctx, returnURL) } - session := oauth.NewSession(userID, time.Now()) + return h.completeAuthorize(ctx, ar, info.UserID, info.AuthTime) +} + +func (h *Handler) completeAuthorize(ctx echo.Context, ar fosite.AuthorizeRequester, userID string, authTime time.Time) error { + c := ctx.Request().Context() + rw := ctx.Response() + + session := oauth.NewSession(userID, authTime) for _, scope := range ar.GetRequestedScopes() { ar.GrantScope(scope) } @@ -54,6 +80,48 @@ func (h *Handler) Authorize(ctx echo.Context, params gen.AuthorizeParams) error return nil } +func (h *Handler) isReauthCompleted(ctx echo.Context, authTime time.Time) bool { + session, err := h.sessions.Get(ctx.Request(), sessionName) + if err != nil { + return false + } + + reqAt, ok := session.Values["reauth_requested_at"].(int64) + if !ok { + return false + } + + return authTime.Unix() > reqAt +} + +func (h *Handler) redirectToLogin(ctx echo.Context, returnURL string) error { + session, err := h.sessions.Get(ctx.Request(), sessionName) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get session") + } + + session.Values["reauth_requested_at"] = time.Now().Unix() + session.Values["authenticated"] = false + + if err := session.Save(ctx.Request(), ctx.Response()); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to save session") + } + + return ctx.Redirect(http.StatusFound, "/login?return_url="+url.QueryEscape(returnURL)) +} + +func (h *Handler) isMaxAgeExpired(ar fosite.AuthorizeRequester, authTime time.Time) bool { + maxAgeStr := ar.GetRequestForm().Get("max_age") + if maxAgeStr == "" { + return false + } + maxAge, err := strconv.ParseInt(maxAgeStr, 10, 64) + if err != nil { + return false + } + return time.Since(authTime) > time.Duration(maxAge)*time.Second +} + func (h *Handler) Token(ctx echo.Context) error { c := ctx.Request().Context() rw := ctx.Response() @@ -122,7 +190,18 @@ func (h *Handler) handleUserInfo(ctx echo.Context, token string) error { return ctx.JSON(http.StatusUnauthorized, gen.OAuthError{Error: gen.InvalidGrant}) } - return ctx.JSON(http.StatusOK, gen.UserInfo{Sub: ar.GetSession().GetSubject()}) + sub := ar.GetSession().GetSubject() + info := gen.UserInfo{Sub: sub} + + if h.userUseCase != nil && ar.GetGrantedScopes().Has("profile") { + user, userErr := h.userUseCase.GetByID(c, sub) + if userErr == nil { + info.Name = &user.TrapID + info.PreferredUsername = &user.TrapID + } + } + + return ctx.JSON(http.StatusOK, info) } func (h *Handler) GetJWKS(ctx echo.Context) error { diff --git a/internal/usecase/user.go b/internal/usecase/user.go new file mode 100644 index 0000000..5a81b41 --- /dev/null +++ b/internal/usecase/user.go @@ -0,0 +1,120 @@ +package usecase + +import ( + "context" + "crypto/sha512" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/pbkdf2" + + "github.com/traPtitech/portal-oidc/internal/domain" + "github.com/traPtitech/portal-oidc/internal/repository" +) + +var ( + ErrInvalidPassword = errors.New("invalid password") + ErrUnsupportedHashAlgorithm = errors.New("unsupported hash algorithm") + ErrUserNotActive = errors.New("user is not active") + ErrUserNotFound = errors.New("user not found") +) + +type UserUseCase interface { + Authenticate(ctx context.Context, trapID, password string) (*domain.User, error) + GetByID(ctx context.Context, id string) (*domain.User, error) +} + +type userUseCase struct { + repo repository.UserRepository +} + +func NewUserUseCase(repo repository.UserRepository) UserUseCase { + return &userUseCase{repo: repo} +} + +func (u *userUseCase) GetByID(ctx context.Context, id string) (*domain.User, error) { + user, err := u.repo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + return user, nil +} + +func (u *userUseCase) Authenticate(ctx context.Context, trapID, password string) (*domain.User, error) { + user, err := u.repo.GetByTrapID(ctx, trapID) + if err != nil { + if errors.Is(err, repository.ErrUserNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + if err := verifyPassword(password, user.PasswordHash); err != nil { + return nil, err + } + + statuses, err := u.repo.ListStatuses(ctx, user.ID) + if err != nil { + return nil, err + } + + if len(statuses) > 0 { + isActive := false + for _, status := range statuses { + if status == "active" { + isActive = true + break + } + } + if !isActive { + return nil, ErrUserNotActive + } + } + + return &user.User, nil +} + +func verifyPassword(password, storedHash string) error { + parts := strings.Split(storedHash, "$") + if len(parts) != 4 { + return fmt.Errorf("%w: invalid hash format", ErrUnsupportedHashAlgorithm) + } + + switch parts[0] { + case "pbkdf2_sha512": + return verifyPBKDF2SHA512(password, parts[1], parts[2], parts[3]) + default: + return fmt.Errorf("%w: %s", ErrUnsupportedHashAlgorithm, parts[0]) + } +} + +func verifyPBKDF2SHA512(password, iterationsStr, saltB64, hashB64 string) error { + iterations, err := strconv.Atoi(iterationsStr) + if err != nil { + return fmt.Errorf("%w: invalid iterations: %w", ErrUnsupportedHashAlgorithm, err) + } + + salt, err := base64.StdEncoding.DecodeString(saltB64) + if err != nil { + return fmt.Errorf("%w: invalid salt: %w", ErrUnsupportedHashAlgorithm, err) + } + + expectedHash, err := base64.StdEncoding.DecodeString(hashB64) + if err != nil { + return fmt.Errorf("%w: invalid hash: %w", ErrUnsupportedHashAlgorithm, err) + } + + computedHash := pbkdf2.Key([]byte(password), salt, iterations, len(expectedHash), sha512.New) + + if subtle.ConstantTimeCompare(computedHash, expectedHash) != 1 { + return ErrInvalidPassword + } + return nil +} From 787620cb935292bb828233b8cef09b2afa58383f Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:07:57 +0900 Subject: [PATCH 18/26] docs: add conformance suite test manual Assisted-by: Claude Code (model: claude-opus-4-6) --- TEST_MANUAL.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 TEST_MANUAL.md diff --git a/TEST_MANUAL.md b/TEST_MANUAL.md new file mode 100644 index 0000000..101ce4d --- /dev/null +++ b/TEST_MANUAL.md @@ -0,0 +1,62 @@ +# Conformance Suite でのテストのやり方 + +## 目的 + +OpenID Connect Conformance Suite を使って本リポジトリの OIDC 実装を手動で検証する。 + +## 1. OIDC サーバーの起動 + +1. 開発環境を起動する + + ```bash + mise run dev + ``` + + `config.yaml`のhostを`http://host.docker.internal:8080` に書き換える必要があるはず。 + +## 2. Discovery の確認 + +次の URL が 200 で返ることを確認する。 + +- OpenID Provider Configuration: `http://localhost:8080/.well-known/openid-configuration` +- JWKS: `http://localhost:8080/.well-known/jwks.json` + +例: + +```bash +curl -sS http://localhost:8080/.well-known/openid-configuration | head -n 5 +curl -sS http://localhost:8080/.well-known/jwks.json | head -n 5 +``` + +## 3. Conformance Suite 用クライアントの作成 + +Conformance Suite が提示する Redirect URI を登録する。`client_type` は `confidential` を推奨。 +``の例: `https://localhost.emobix.co.uk:8443/test/a/alias/callback` <- 一度Create Test Planをすると表示されたはず。 + +```bash +curl -sS -X POST http://localhost:8080/api/v1/admin/clients \ + -H 'Content-Type: application/json' \ + -d '{"name":"conformance-suite","client_type":"confidential","redirect_uris":[""]}' +``` + +レスポンスに `client_id` と `client_secret` が含まれるので控えておく。 + +## 4. Conformance Suite の起動 + +```bash +git clone git@github.com:openid-certification/conformance-suite.git +docker compose up +``` + +## 5. Suite 側の設定 + +Suite の新規テスト作成画面で、以下を設定する。 + +- Alias: 好きな名称 +- Client ID: 手順 3 で作成した `client_id` +- Client Secret: 手順 3 で作成した `client_secret` +- Discovery URL: `http://host.docker.internal:8080/.well-known/openid-configuration` + +## 7. 実行と結果確認 + +Suite からテストを実行し、失敗したテスト項目のログを確認して修正する。 From 2518037c965bb45e168e2f2d68f910214db6c2b5 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 04:50:00 +0900 Subject: [PATCH 19/26] fix(oauth): fix OIDC session storage, PKCE config, and dev mode setup Widen oidc_sessions.authorize_code to varchar(255) because fosite passes the full HMAC code (~87 chars) to CreateOpenIDConnectSession. Disable global PKCE enforcement while keeping it for public clients. Support PKCS#8 key format in addition to PKCS#1. Skip portal database connection in non-production environments. Assisted-by: Claude Code (model: claude-opus-4-6) --- cmd/oauth.go | 20 ++++++++++++++++++-- cmd/serve.go | 14 ++++++++------ db/schema.sql | 2 +- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cmd/oauth.go b/cmd/oauth.go index 5fed6d2..4ed022e 100644 --- a/cmd/oauth.go +++ b/cmd/oauth.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/pem" "errors" + "fmt" "io" "os" "path/filepath" @@ -47,7 +48,7 @@ func newOAuthProvider(storage fosite.Storage, config OAuthProviderConfig, privat ScopeStrategy: fosite.ExactScopeStrategy, AudienceMatchingStrategy: fosite.DefaultAudienceMatchingStrategy, SendDebugMessagesToClients: false, - EnforcePKCE: true, + EnforcePKCE: false, EnforcePKCEForPublicClients: true, EnablePKCEPlainChallengeMethod: false, AccessTokenIssuer: config.Issuer, @@ -130,7 +131,22 @@ func loadKey(path string) (key *rsa.PrivateKey, err error) { return nil, errors.New("failed to decode PEM") } - return x509.ParsePKCS1PrivateKey(block.Bytes) + switch block.Type { + case "RSA PRIVATE KEY": + return x509.ParsePKCS1PrivateKey(block.Bytes) + case "PRIVATE KEY": + parsed, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return nil, pkcs8Err + } + rsaKey, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return rsaKey, nil + default: + return nil, fmt.Errorf("unsupported PEM type: %s", block.Type) + } } func openKeyRoot(path string) (*os.Root, string, error) { diff --git a/cmd/serve.go b/cmd/serve.go index 9234662..6e71157 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -25,11 +25,6 @@ func newServer(cfg Config) (http.Handler, error) { return nil, err } - portalQueries, err := setupPortalDatabase(cfg.Portal.Database) - if err != nil { - return nil, err - } - privateKey, err := loadOrGenerateKey(cfg.OAuth.KeyFile) if err != nil { return nil, fmt.Errorf("failed to load/generate RSA key: %w", err) @@ -54,7 +49,14 @@ func newServer(cfg Config) (http.Handler, error) { Secret: []byte(cfg.OAuth.Secret), }, privateKey) - userUseCase := usecase.NewUserUseCase(repository.NewUserRepository(portalQueries)) + var userUseCase usecase.UserUseCase + if cfg.Environment == "production" { + portalQueries, portalErr := setupPortalDatabase(cfg.Portal.Database) + if portalErr != nil { + return nil, portalErr + } + userUseCase = usecase.NewUserUseCase(repository.NewUserRepository(portalQueries)) + } handler := v1.NewHandler( usecase.NewClientUseCase(clientRepo), oauth2Provider, diff --git a/db/schema.sql b/db/schema.sql index 15ffdad..0990e42 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS `tokens` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; CREATE TABLE IF NOT EXISTS `oidc_sessions` ( - `authorize_code` varchar(64) NOT NULL, + `authorize_code` varchar(255) NOT NULL, `client_id` char(36) NOT NULL, `user_id` char(36) NOT NULL, `scopes` text NOT NULL, From 47bd92c8e3aeddd58e37c267b4758e67ec24417b Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:15:43 +0900 Subject: [PATCH 20/26] ci(conformance): add OIDC conformance suite workflow Add CI workflow that runs the OIDC Basic Certification test plan using pre-built conformance suite images from GHCR. Includes a separate workflow_dispatch to build and publish the conformance suite images. Passes 21/34 conformance tests. Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/conformance/config.template.json | 11 + .github/conformance/docker-compose.yml | 27 ++ .github/conformance/expected-skips.json | 1 + .github/conformance/run.sh | 51 ++++ .github/scripts/run-test-plan.py | 259 ++++++++++++++++++ .../workflows/build-conformance-suite.yaml | 62 +++++ .github/workflows/conformance.yaml | 147 ++++++++++ .gitignore | 3 + 8 files changed, 561 insertions(+) create mode 100644 .github/conformance/config.template.json create mode 100644 .github/conformance/docker-compose.yml create mode 100644 .github/conformance/expected-skips.json create mode 100755 .github/conformance/run.sh create mode 100755 .github/scripts/run-test-plan.py create mode 100644 .github/workflows/build-conformance-suite.yaml create mode 100644 .github/workflows/conformance.yaml diff --git a/.github/conformance/config.template.json b/.github/conformance/config.template.json new file mode 100644 index 0000000..8931fd5 --- /dev/null +++ b/.github/conformance/config.template.json @@ -0,0 +1,11 @@ +{ + "alias": "portal-oidc", + "description": "portal-oidc OIDC conformance test", + "server": { + "discoveryUrl": "${DISCOVERY_URL}" + }, + "client": { + "client_id": "${CLIENT_ID}", + "client_secret": "${CLIENT_SECRET}" + } +} diff --git a/.github/conformance/docker-compose.yml b/.github/conformance/docker-compose.yml new file mode 100644 index 0000000..0e810ad --- /dev/null +++ b/.github/conformance/docker-compose.yml @@ -0,0 +1,27 @@ +services: + mongodb: + image: mongo:6.0.13 + httpd: + image: ${CONFORMANCE_HTTPD_IMAGE} + ports: + - "8443:8443" + depends_on: + - server + server: + image: ${CONFORMANCE_SERVER_IMAGE} + command: > + java + -jar /server/fapi-test-suite.jar + -Djdk.tls.maxHandshakeMessageSize=65536 + --fintechlabs.base_url=https://localhost.emobix.co.uk:8443 + --fintechlabs.devmode=true + --fintechlabs.startredir=true + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - mongodb + logging: + driver: json-file + options: + max-size: "500k" + max-file: "5" diff --git a/.github/conformance/expected-skips.json b/.github/conformance/expected-skips.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.github/conformance/expected-skips.json @@ -0,0 +1 @@ +[] diff --git a/.github/conformance/run.sh b/.github/conformance/run.sh new file mode 100755 index 0000000..ff9d60e --- /dev/null +++ b/.github/conformance/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +PORTAL_OIDC_URL="${PORTAL_OIDC_URL:-http://localhost:8080}" +CONFORMANCE_SERVER="${CONFORMANCE_SERVER:-https://localhost:8443}" +CONFORMANCE_TOKEN="${CONFORMANCE_TOKEN-}" +DISCOVERY_URL="${DISCOVERY_URL:-http://host.docker.internal:8080/.well-known/openid-configuration}" +REDIRECT_URI="https://localhost.emobix.co.uk:8443/test/a/portal-oidc/callback" +OIDC_SERVER_LOCAL="${OIDC_SERVER_LOCAL:-localhost:8080}" + +TEST_PLAN="oidcc-basic-certification-test-plan" +TEST_VARIANT='{"server_metadata":"discovery","client_registration":"static_client"}' + +mkdir -p "$SCRIPT_DIR/results" + +echo "==> Creating test client..." +RESPONSE=$(curl -sf -X POST "$PORTAL_OIDC_URL/api/v1/admin/clients" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"conformance-suite\", + \"client_type\": \"confidential\", + \"redirect_uris\": [\"$REDIRECT_URI\"] + }") + +CLIENT_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])") +CLIENT_SECRET=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])") + +echo " client_id=$CLIENT_ID" +echo " client_secret=***" + +echo "==> Generating test config..." +sed \ + -e "s|\${DISCOVERY_URL}|$DISCOVERY_URL|g" \ + -e "s|\${CLIENT_ID}|$CLIENT_ID|g" \ + -e "s|\${CLIENT_SECRET}|$CLIENT_SECRET|g" \ + "$SCRIPT_DIR/config.template.json" > "$SCRIPT_DIR/results/config.json" + +echo "==> Running conformance test plan: $TEST_PLAN" +python3 "$REPO_DIR/.github/scripts/run-test-plan.py" \ + --server "$CONFORMANCE_SERVER" \ + --token "$CONFORMANCE_TOKEN" \ + --plan "$TEST_PLAN" \ + --variant "$TEST_VARIANT" \ + --config "$SCRIPT_DIR/results/config.json" \ + --output "$SCRIPT_DIR/results" \ + --oidc-server "$OIDC_SERVER_LOCAL" + +echo "==> Done. Results saved to $SCRIPT_DIR/results/" diff --git a/.github/scripts/run-test-plan.py b/.github/scripts/run-test-plan.py new file mode 100755 index 0000000..73b6047 --- /dev/null +++ b/.github/scripts/run-test-plan.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Run an OpenID Connect conformance suite test plan.""" + +import argparse +import json +import os +import re +import sys +import time + +import httpx + +DEV_MODE = os.environ.get("CONFORMANCE_DEV_MODE", "0") == "1" + + +def create_api_client(base_url: str, token: str) -> httpx.Client: + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + return httpx.Client( + base_url=base_url, + headers=headers, + verify=not DEV_MODE, + timeout=60.0, + ) + + +def create_browser_client() -> httpx.Client: + return httpx.Client( + verify=False, + timeout=30.0, + ) + + +def create_test_plan( + client: httpx.Client, plan_name: str, variant: dict | None, config: dict +) -> dict: + params: dict[str, str] = {"planName": plan_name} + if variant: + params["variant"] = json.dumps(variant) + resp = client.post( + "/api/plan", + params=params, + json=config, + ) + resp.raise_for_status() + return resp.json() + + +def get_test_module_info(client: httpx.Client, module_id: str) -> dict: + resp = client.get(f"/api/info/{module_id}") + resp.raise_for_status() + return resp.json() + + +def start_test_module(client: httpx.Client, plan_id: str, module_name: str) -> dict: + resp = client.post( + "/api/runner", + params={"test": module_name, "plan": plan_id}, + ) + resp.raise_for_status() + return resp.json() + + +def get_test_log(client: httpx.Client, module_id: str) -> list: + resp = client.get(f"/api/log/{module_id}") + resp.raise_for_status() + return resp.json() + + +def find_authorize_url(log_entries: list) -> str | None: + for entry in log_entries: + url = entry.get("redirect_to_authorization_endpoint", "") + if url: + return url + return None + + +def perform_browser_interaction( + api_client: httpx.Client, + browser: httpx.Client, + module_id: str, + oidc_server_url: str, +) -> None: + log = get_test_log(api_client, module_id) + auth_url = find_authorize_url(log) + if not auth_url: + return + + if oidc_server_url: + auth_url = auth_url.replace("host.docker.internal:8080", oidc_server_url) + + print(f" Browser: visiting authorize URL") + try: + resp = browser.get(auth_url, follow_redirects=False) + except httpx.HTTPError as e: + print(f" Browser: authorize request failed: {e}") + return + + if resp.status_code not in (301, 302, 303, 307, 308): + print(f" Browser: OP returned {resp.status_code} (no redirect)") + return + + callback_url = resp.headers.get("location", "") + if not callback_url: + print(f" Browser: redirect with no location header") + return + + print(f" Browser: following redirect to callback") + try: + cb_resp = browser.get(callback_url) + except httpx.HTTPError as e: + print(f" Browser: callback request failed: {e}") + return + + match = re.search(r"xhr\.open\('POST',\s*\"([^\"]+)\"", cb_resp.text) + if not match: + print(" Browser: no implicit submit URL found in callback page") + return + + implicit_url = match.group(1).replace("\\/", "/") + print(f" Browser: submitting fragment to implicit endpoint") + try: + browser.post(implicit_url, content="", headers={"Content-Type": "text/plain"}) + except httpx.HTTPError as e: + print(f" Browser: implicit submit failed: {e}") + + +def wait_for_test( + api_client: httpx.Client, + browser: httpx.Client, + module_id: str, + oidc_server_url: str, + timeout: int = 60, +) -> dict: + start = time.time() + browser_tried = False + while time.time() - start < timeout: + info = get_test_module_info(api_client, module_id) + status = info.get("status", "UNKNOWN") + if status in ("FINISHED", "INTERRUPTED"): + return info + if status == "WAITING" and not browser_tried: + browser_tried = True + perform_browser_interaction( + api_client, browser, module_id, oidc_server_url + ) + time.sleep(2) + raise TimeoutError(f"Test {module_id} did not finish within {timeout}s") + + +def run_plan( + api_client: httpx.Client, + browser: httpx.Client, + plan_name: str, + variant: dict | None, + config: dict, + output_dir: str, + oidc_server_url: str, +) -> bool: + print(f"Creating test plan: {plan_name}") + plan = create_test_plan(api_client, plan_name, variant, config) + plan_id = plan["id"] + modules = plan.get("modules", []) + print(f"Plan ID: {plan_id}") + print(f"Modules to run: {len(modules)}") + + all_passed = True + results = [] + + for module_entry in modules: + module_name = module_entry["testModule"] + print(f"\n--- Running: {module_name} ---") + + started = start_test_module(api_client, plan_id, module_name) + module_id = started["id"] + print(f"Module ID: {module_id}") + + try: + info = wait_for_test( + api_client, browser, module_id, oidc_server_url + ) + except TimeoutError as e: + print(f"TIMEOUT: {e}") + all_passed = False + results.append({"module": module_name, "result": "TIMEOUT"}) + continue + + result = info.get("result", "UNKNOWN") + print(f"Result: {result}") + + log = get_test_log(api_client, module_id) + log_path = os.path.join(output_dir, f"{module_name}.json") + with open(log_path, "w") as f: + json.dump(log, f, indent=2) + + results.append({"module": module_name, "result": result}) + + if result not in ("PASSED", "WARNING", "REVIEW"): + all_passed = False + + summary_path = os.path.join(output_dir, "summary.json") + with open(summary_path, "w") as f: + json.dump( + {"plan_id": plan_id, "plan_name": plan_name, "results": results}, + f, + indent=2, + ) + + print("\n=== Summary ===") + for r in results: + status_mark = "PASS" if r["result"] in ("PASSED", "WARNING", "REVIEW") else "FAIL" + print(f" [{status_mark}] {r['module']}: {r['result']}") + + return all_passed + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run OIDC conformance test plan") + parser.add_argument("--server", required=True, help="Conformance suite base URL") + parser.add_argument("--token", default="", help="API bearer token") + parser.add_argument("--plan", required=True, help="Test plan name") + parser.add_argument("--variant", default=None, help="Variant selection as JSON") + parser.add_argument("--config", required=True, help="Path to test config JSON") + parser.add_argument("--output", required=True, help="Output directory for results") + parser.add_argument( + "--oidc-server", default="", + help="Local OIDC server host:port for URL rewriting (e.g., localhost:8080)", + ) + args = parser.parse_args() + + with open(args.config) as f: + config = json.load(f) + + variant = json.loads(args.variant) if args.variant else None + + os.makedirs(args.output, exist_ok=True) + + api_client = create_api_client(args.server, args.token) + browser = create_browser_client() + + try: + passed = run_plan( + api_client, browser, args.plan, variant, config, args.output, + args.oidc_server, + ) + finally: + api_client.close() + browser.close() + + if not passed: + print("\nSome tests failed.") + sys.exit(1) + + print("\nAll tests passed.") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build-conformance-suite.yaml b/.github/workflows/build-conformance-suite.yaml new file mode 100644 index 0000000..f1006c8 --- /dev/null +++ b/.github/workflows/build-conformance-suite.yaml @@ -0,0 +1,62 @@ +name: Build Conformance Suite Images + +on: + workflow_dispatch: + inputs: + tag: + description: "Conformance suite release tag (e.g. release-v5.1.35)" + required: true + default: "release-v5.1.35" + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ghcr.io/traptitech/portal-oidc/conformance + +jobs: + build: + name: Build and Push + runs-on: ubuntu-latest + steps: + - name: Clone conformance suite + run: | + git clone --depth 1 --branch "${{ inputs.tag }}" \ + https://gitlab.com/openid/conformance-suite.git + + - name: Build with Maven + working-directory: conformance-suite + env: + MAVEN_CACHE: /tmp/m2 + run: | + mkdir -p "$MAVEN_CACHE" + docker compose -f builder-compose.yml run --rm builder + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push httpd + run: | + docker build \ + -t "$IMAGE_PREFIX-httpd:${{ inputs.tag }}" \ + conformance-suite/httpd + docker push "$IMAGE_PREFIX-httpd:${{ inputs.tag }}" + + - name: Build and push server + run: | + cat > /tmp/Dockerfile <<'EOF' + FROM eclipse-temurin:17 + RUN apt-get update && apt-get install -y redir && rm -rf /var/lib/apt/lists/* + COPY fapi-test-suite.jar /server/fapi-test-suite.jar + EOF + cp conformance-suite/target/fapi-test-suite.jar /tmp/ + docker build \ + -t "$IMAGE_PREFIX-server:${{ inputs.tag }}" \ + /tmp + docker push "$IMAGE_PREFIX-server:${{ inputs.tag }}" diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml new file mode 100644 index 0000000..de4cdf2 --- /dev/null +++ b/.github/workflows/conformance.yaml @@ -0,0 +1,147 @@ +name: OIDC Conformance Suite + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: read + +env: + GO_VERSION_FILE: go.mod + CONFORMANCE_SUITE_TAG: release-v5.1.35 + IMAGE_PREFIX: ghcr.io/traptitech/portal-oidc/conformance + +jobs: + conformance: + name: Conformance Suite + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + services: + oidc-db: + image: mariadb:10.11.15 + env: + MARIADB_ROOT_PASSWORD: password + MARIADB_DATABASE: oidc + ports: + - 3307:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: ${{ env.GO_VERSION_FILE }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Python dependencies + run: pip install httpx + + - name: Apply DB schema + run: | + mysql -h 127.0.0.1 -P 3307 -u root -ppassword oidc < db/schema.sql + + - name: Build portal-oidc + run: go build -o portal-oidc ./cmd/ + + - name: Generate RSA key + run: | + mkdir -p data + openssl genpkey -algorithm RSA -out data/private.pem -pkeyopt rsa_keygen_bits:2048 + + - name: Start portal-oidc + run: | + ./portal-oidc serve > /tmp/portal-oidc.log 2>&1 & + echo $! > /tmp/portal-oidc.pid + env: + OIDC_HOST: http://host.docker.internal:8080 + OIDC_DATABASE__HOST: 127.0.0.1 + OIDC_DATABASE__PORT: "3307" + OIDC_DATABASE__USER: root + OIDC_DATABASE__PASSWORD: password + OIDC_DATABASE__NAME: oidc + OIDC_OAUTH__SECRET: conformance-test-secret-key-32!! + + - name: Wait for portal-oidc health check + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo "portal-oidc is ready" + exit 0 + fi + echo "Waiting for portal-oidc... ($i/30)" + sleep 2 + done + echo "portal-oidc failed to start" + exit 1 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Start conformance suite + run: docker compose -f .github/conformance/docker-compose.yml up -d + env: + CONFORMANCE_HTTPD_IMAGE: ${{ env.IMAGE_PREFIX }}-httpd:${{ env.CONFORMANCE_SUITE_TAG }} + CONFORMANCE_SERVER_IMAGE: ${{ env.IMAGE_PREFIX }}-server:${{ env.CONFORMANCE_SUITE_TAG }} + + - name: Wait for conformance suite + run: | + for i in $(seq 1 60); do + if curl -sfk https://localhost:8443 > /dev/null 2>&1; then + echo "Conformance suite is ready" + exit 0 + fi + echo "Waiting for conformance suite... ($i/60)" + sleep 5 + done + echo "Conformance suite failed to start" + docker compose -f .github/conformance/docker-compose.yml logs + exit 1 + + - name: Run conformance tests + working-directory: ${{ github.workspace }} + run: | + chmod +x .github/conformance/run.sh + bash .github/conformance/run.sh + env: + CONFORMANCE_SERVER: https://localhost:8443 + CONFORMANCE_TOKEN: "" + CONFORMANCE_DEV_MODE: "1" + PORTAL_OIDC_URL: http://localhost:8080 + DISCOVERY_URL: http://host.docker.internal:8080/.well-known/openid-configuration + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: conformance-results + path: .github/conformance/results/ + retention-days: 30 + + - name: Portal OIDC server logs + if: always() + run: cat /tmp/portal-oidc.log || true + + - name: Conformance suite logs + if: failure() + run: docker compose -f .github/conformance/docker-compose.yml logs --tail=200 diff --git a/.gitignore b/.gitignore index 3653d52..74faab4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ Thumbs.db # Local data (secrets, keys) data/ + +# Conformance suite results +.github/conformance/results/ From 27318974d830a6b08a96a99dc95213c4eff5510a Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:11:52 +0900 Subject: [PATCH 21/26] fix(ci): move conformance image env vars to job level CONFORMANCE_HTTPD_IMAGE and CONFORMANCE_SERVER_IMAGE were only defined in the "Start conformance suite" step env. Other steps calling docker compose (logs in wait step and failure diagnostics) lacked these variables, causing compose to fail with "service has neither an image nor a build context". Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/workflows/conformance.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index de4cdf2..b0a9567 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -26,6 +26,9 @@ jobs: permissions: contents: read packages: read + env: + CONFORMANCE_HTTPD_IMAGE: ${{ env.IMAGE_PREFIX }}-httpd:${{ env.CONFORMANCE_SUITE_TAG }} + CONFORMANCE_SERVER_IMAGE: ${{ env.IMAGE_PREFIX }}-server:${{ env.CONFORMANCE_SUITE_TAG }} services: oidc-db: image: mariadb:10.11.15 @@ -100,9 +103,6 @@ jobs: - name: Start conformance suite run: docker compose -f .github/conformance/docker-compose.yml up -d - env: - CONFORMANCE_HTTPD_IMAGE: ${{ env.IMAGE_PREFIX }}-httpd:${{ env.CONFORMANCE_SUITE_TAG }} - CONFORMANCE_SERVER_IMAGE: ${{ env.IMAGE_PREFIX }}-server:${{ env.CONFORMANCE_SUITE_TAG }} - name: Wait for conformance suite run: | From 7891c47ff2a5999a224ff05adc7d37c363afaa31 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:20:52 +0900 Subject: [PATCH 22/26] fix(ci): define conformance image env vars at workflow level The previous commit moved image env vars to job-level env using ${{ env.X }}, but the env context is not available at job level. Define full image names directly at workflow level instead. Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/workflows/conformance.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index b0a9567..9ded67f 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -16,8 +16,8 @@ permissions: env: GO_VERSION_FILE: go.mod - CONFORMANCE_SUITE_TAG: release-v5.1.35 - IMAGE_PREFIX: ghcr.io/traptitech/portal-oidc/conformance + CONFORMANCE_HTTPD_IMAGE: ghcr.io/traptitech/portal-oidc/conformance-httpd:release-v5.1.35 + CONFORMANCE_SERVER_IMAGE: ghcr.io/traptitech/portal-oidc/conformance-server:release-v5.1.35 jobs: conformance: @@ -26,9 +26,6 @@ jobs: permissions: contents: read packages: read - env: - CONFORMANCE_HTTPD_IMAGE: ${{ env.IMAGE_PREFIX }}-httpd:${{ env.CONFORMANCE_SUITE_TAG }} - CONFORMANCE_SERVER_IMAGE: ${{ env.IMAGE_PREFIX }}-server:${{ env.CONFORMANCE_SUITE_TAG }} services: oidc-db: image: mariadb:10.11.15 From e2a67a8c4c30734477c26b03a12c4ac31ced4d90 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:06:22 +0900 Subject: [PATCH 23/26] fix(ci): address PR review feedback for conformance suite - Add --expected-skips support to skip specified test modules - Add test result statistics (passed/failed/skipped counts) - Use jq instead of python3 for JSON parsing in run.sh - Move JVM property before -jar in docker-compose command - Use MYSQL_PWD env var instead of -p flag - Remove unnecessary f-string prefixes - Add SSL verification comment for clarity Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/conformance/docker-compose.yml | 2 +- .github/conformance/run.sh | 12 +++++-- .github/scripts/run-test-plan.py | 49 ++++++++++++++++++++++---- .github/workflows/conformance.yaml | 5 +-- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.github/conformance/docker-compose.yml b/.github/conformance/docker-compose.yml index 0e810ad..42f330c 100644 --- a/.github/conformance/docker-compose.yml +++ b/.github/conformance/docker-compose.yml @@ -11,8 +11,8 @@ services: image: ${CONFORMANCE_SERVER_IMAGE} command: > java - -jar /server/fapi-test-suite.jar -Djdk.tls.maxHandshakeMessageSize=65536 + -jar /server/fapi-test-suite.jar --fintechlabs.base_url=https://localhost.emobix.co.uk:8443 --fintechlabs.devmode=true --fintechlabs.startredir=true diff --git a/.github/conformance/run.sh b/.github/conformance/run.sh index ff9d60e..cf97cb5 100755 --- a/.github/conformance/run.sh +++ b/.github/conformance/run.sh @@ -25,8 +25,13 @@ RESPONSE=$(curl -sf -X POST "$PORTAL_OIDC_URL/api/v1/admin/clients" \ \"redirect_uris\": [\"$REDIRECT_URI\"] }") -CLIENT_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])") -CLIENT_SECRET=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_secret'])") +CLIENT_ID=$(echo "$RESPONSE" | jq -r '.client_id') +CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.client_secret') + +if [[ -z "$CLIENT_ID" || -z "$CLIENT_SECRET" ]]; then + echo "Error: Failed to extract client credentials from response" + exit 1 +fi echo " client_id=$CLIENT_ID" echo " client_secret=***" @@ -46,6 +51,7 @@ python3 "$REPO_DIR/.github/scripts/run-test-plan.py" \ --variant "$TEST_VARIANT" \ --config "$SCRIPT_DIR/results/config.json" \ --output "$SCRIPT_DIR/results" \ - --oidc-server "$OIDC_SERVER_LOCAL" + --oidc-server "$OIDC_SERVER_LOCAL" \ + --expected-skips "$SCRIPT_DIR/expected-skips.json" echo "==> Done. Results saved to $SCRIPT_DIR/results/" diff --git a/.github/scripts/run-test-plan.py b/.github/scripts/run-test-plan.py index 73b6047..3d0dbb1 100755 --- a/.github/scripts/run-test-plan.py +++ b/.github/scripts/run-test-plan.py @@ -26,6 +26,7 @@ def create_api_client(base_url: str, token: str) -> httpx.Client: def create_browser_client() -> httpx.Client: + # SSL verification disabled: conformance suite uses self-signed certificates return httpx.Client( verify=False, timeout=30.0, @@ -90,7 +91,7 @@ def perform_browser_interaction( if oidc_server_url: auth_url = auth_url.replace("host.docker.internal:8080", oidc_server_url) - print(f" Browser: visiting authorize URL") + print(" Browser: visiting authorize URL") try: resp = browser.get(auth_url, follow_redirects=False) except httpx.HTTPError as e: @@ -103,10 +104,10 @@ def perform_browser_interaction( callback_url = resp.headers.get("location", "") if not callback_url: - print(f" Browser: redirect with no location header") + print(" Browser: redirect with no location header") return - print(f" Browser: following redirect to callback") + print(" Browser: following redirect to callback") try: cb_resp = browser.get(callback_url) except httpx.HTTPError as e: @@ -119,7 +120,7 @@ def perform_browser_interaction( return implicit_url = match.group(1).replace("\\/", "/") - print(f" Browser: submitting fragment to implicit endpoint") + print(" Browser: submitting fragment to implicit endpoint") try: browser.post(implicit_url, content="", headers={"Content-Type": "text/plain"}) except httpx.HTTPError as e: @@ -149,6 +150,14 @@ def wait_for_test( raise TimeoutError(f"Test {module_id} did not finish within {timeout}s") +def load_expected_skips(path: str | None) -> set[str]: + if not path or not os.path.exists(path): + return set() + with open(path) as f: + entries = json.load(f) + return {e["test_module"] for e in entries} + + def run_plan( api_client: httpx.Client, browser: httpx.Client, @@ -157,6 +166,7 @@ def run_plan( config: dict, output_dir: str, oidc_server_url: str, + expected_skips: set[str] | None = None, ) -> bool: print(f"Creating test plan: {plan_name}") plan = create_test_plan(api_client, plan_name, variant, config) @@ -168,8 +178,14 @@ def run_plan( all_passed = True results = [] + skips = expected_skips or set() + for module_entry in modules: module_name = module_entry["testModule"] + if module_name in skips: + print(f"\n--- Skipping: {module_name} (expected skip) ---") + results.append({"module": module_name, "result": "SKIPPED"}) + continue print(f"\n--- Running: {module_name} ---") started = start_test_module(api_client, plan_id, module_name) @@ -196,7 +212,7 @@ def run_plan( results.append({"module": module_name, "result": result}) - if result not in ("PASSED", "WARNING", "REVIEW"): + if result not in ("PASSED", "WARNING", "REVIEW", "SKIPPED"): all_passed = False summary_path = os.path.join(output_dir, "summary.json") @@ -208,10 +224,24 @@ def run_plan( ) print("\n=== Summary ===") + passed_count = 0 + failed_count = 0 + skipped_count = 0 for r in results: - status_mark = "PASS" if r["result"] in ("PASSED", "WARNING", "REVIEW") else "FAIL" + if r["result"] == "SKIPPED": + status_mark = "SKIP" + skipped_count += 1 + elif r["result"] in ("PASSED", "WARNING", "REVIEW"): + status_mark = "PASS" + passed_count += 1 + else: + status_mark = "FAIL" + failed_count += 1 print(f" [{status_mark}] {r['module']}: {r['result']}") + total = len(results) + print(f"\n Total: {total} Passed: {passed_count} Failed: {failed_count} Skipped: {skipped_count}") + return all_passed @@ -227,12 +257,17 @@ def main() -> None: "--oidc-server", default="", help="Local OIDC server host:port for URL rewriting (e.g., localhost:8080)", ) + parser.add_argument( + "--expected-skips", default=None, + help="Path to JSON file listing test modules to skip", + ) args = parser.parse_args() with open(args.config) as f: config = json.load(f) variant = json.loads(args.variant) if args.variant else None + skips = load_expected_skips(args.expected_skips) os.makedirs(args.output, exist_ok=True) @@ -242,7 +277,7 @@ def main() -> None: try: passed = run_plan( api_client, browser, args.plan, variant, config, args.output, - args.oidc_server, + args.oidc_server, skips, ) finally: api_client.close() diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 9ded67f..9e5640d 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -54,8 +54,9 @@ jobs: run: pip install httpx - name: Apply DB schema - run: | - mysql -h 127.0.0.1 -P 3307 -u root -ppassword oidc < db/schema.sql + run: mysql -h 127.0.0.1 -P 3307 -u root oidc < db/schema.sql + env: + MYSQL_PWD: password - name: Build portal-oidc run: go build -o portal-oidc ./cmd/ From ce906843614594ddfde0c257662cf4988451f039 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:11:43 +0900 Subject: [PATCH 24/26] fix(deps): upgrade otel/sdk to v1.40.0 for GO-2026-4394 Fixes PATH hijacking vulnerability in go.opentelemetry.io/otel/sdk. Assisted-by: Claude Code (model: claude-opus-4-6) --- go.mod | 12 ++++++------ go.sum | 34 ++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index efeae42..3f82e48 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect - github.com/go-logr/logr v1.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobuffalo/pop/v6 v6.1.1 // indirect @@ -63,7 +63,6 @@ require ( github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.9.5 // indirect @@ -76,19 +75,20 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect - go.opentelemetry.io/otel v1.21.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect - go.opentelemetry.io/otel/metric v1.21.0 // indirect - go.opentelemetry.io/otel/sdk v1.21.0 // indirect - go.opentelemetry.io/otel/trace v1.21.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect golang.org/x/mod v0.31.0 // indirect diff --git a/go.sum b/go.sum index c51be6a..0ff32c1 100644 --- a/go.sum +++ b/go.sum @@ -108,8 +108,8 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -185,8 +185,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -379,8 +379,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -459,6 +459,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= @@ -469,8 +471,8 @@ go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 h1:f4beMGDKiVzg9IcX7/VuWV go.opentelemetry.io/contrib/propagators/jaeger v1.21.1/go.mod h1:U9jhkEl8d1LL+QXY7q3kneJWJugiN3kZJV2OWz3hkBY= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 h1:Qb+5A+JbIjXwO7l4HkRUhgIn4Bzz0GNS2q+qdmSx+0c= go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1/go.mod h1:G4vNCm7fRk0kjZ6pGNLo5SpLxAUvOfSrcaegnT8TPck= -go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= -go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 h1:cl5P5/GIfFh4t6xyruOgJP5QiA1pw4fYYdv6nc6CBWw= @@ -479,18 +481,22 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkE go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= go.opentelemetry.io/otel/exporters/zipkin v1.21.0 h1:D+Gv6lSfrFBWmQYyxKjDd0Zuld9SRXpIrEsKZvE4DO4= go.opentelemetry.io/otel/exporters/zipkin v1.21.0/go.mod h1:83oMKR6DzmHisFOW3I+yIMGZUTjxiWaiBI8M8+TU5zE= -go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= -go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= -go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= -go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= -go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= From ed122a9b099ddb110ff632692ce8af8b3ad4a8e9 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:20:30 +0900 Subject: [PATCH 25/26] refactor(ci): merge vulnerability check into security scan job Reduces CI check noise by consolidating govulncheck, gosec, and Trivy into a single Security Scan job. Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/workflows/ci.yaml | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e084f8..b69d8f6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -101,11 +101,12 @@ jobs: files: coverage.out token: ${{ secrets.CODECOV_TOKEN }} - vulnerability: - name: Vulnerability Check + security: + name: Security Scan runs-on: ubuntu-latest permissions: contents: read + security-events: write steps: - uses: actions/checkout@v6 @@ -118,19 +119,6 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - security: - name: Security Scan - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-go@v6 - with: - go-version-file: ${{ env.GO_VERSION_FILE }} - - name: Run Gosec Security Scanner uses: securego/gosec@master with: From ce4858f1b848c6003dde324e16688ee7823d9571 Mon Sep 17 00:00:00 2001 From: anko9801 <37263451+anko9801@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:23:41 +0900 Subject: [PATCH 26/26] refactor(ci): merge gosec and Trivy SARIF uploads into one Combine SARIF outputs into a single upload to reduce the number of Code scanning results checks on PRs. Assisted-by: Claude Code (model: claude-opus-4-6) --- .github/workflows/ci.yaml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b69d8f6..8cb0334 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,12 +124,6 @@ jobs: with: args: -exclude-generated -fmt sarif -out gosec.sarif ./... - - name: Upload Gosec SARIF - uses: github/codeql-action/upload-sarif@v4 - if: always() && hashFiles('gosec.sarif') != '' - with: - sarif_file: gosec.sarif - - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@0.33.1 with: @@ -138,8 +132,15 @@ jobs: output: 'trivy.sarif' exit-code: '0' - - name: Upload Trivy SARIF + - name: Merge SARIF results + if: always() + run: | + jq -s '{ "$schema": .[0]["$schema"], version: .[0].version, runs: [.[].runs[]] }' \ + gosec.sarif trivy.sarif > security.sarif + + - name: Upload SARIF uses: github/codeql-action/upload-sarif@v4 - if: always() && hashFiles('trivy.sarif') != '' + if: always() && hashFiles('security.sarif') != '' with: - sarif_file: trivy.sarif + sarif_file: security.sarif + category: security