The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
553 lines
16 KiB
Go
553 lines
16 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package connect_v1
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"go.gearno.de/kit/httpserver"
|
|
"go.gearno.de/kit/log"
|
|
"go.probo.inc/probo/pkg/baseurl"
|
|
"go.probo.inc/probo/pkg/bearertoken"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
"go.probo.inc/probo/pkg/iam/oauth2"
|
|
"go.probo.inc/probo/pkg/securecookie"
|
|
"go.probo.inc/probo/pkg/server/api/authn"
|
|
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
|
|
"go.probo.inc/probo/pkg/uri"
|
|
)
|
|
|
|
type OAuth2Handler struct {
|
|
iam *iam.Service
|
|
sessionCookie *authn.Cookie
|
|
baseURL *baseurl.BaseURL
|
|
logger *log.Logger
|
|
}
|
|
|
|
func NewOAuth2Handler(
|
|
svc *iam.Service,
|
|
cookieConfig securecookie.Config,
|
|
baseURL *baseurl.BaseURL,
|
|
logger *log.Logger,
|
|
) *OAuth2Handler {
|
|
return &OAuth2Handler{
|
|
iam: svc,
|
|
sessionCookie: authn.NewCookie(&cookieConfig),
|
|
baseURL: baseURL,
|
|
logger: logger.Named("oauth2"),
|
|
}
|
|
}
|
|
|
|
// ClientAuthMiddleware authenticates the OAuth2 client from HTTP Basic auth
|
|
// or POST body credentials and stores it in the request context.
|
|
func (h *OAuth2Handler) ClientAuthMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
client, err := h.authenticateClient(r)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
|
return
|
|
}
|
|
|
|
ctx := oauth2.ContextWithClient(r.Context(), client)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// BearerTokenMiddleware validates the OAuth2 bearer token from the
|
|
// Authorization header and stores the access token in the request context.
|
|
func (h *OAuth2Handler) BearerTokenMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tokenValue, err := bearertoken.Parse(r.Header.Get("Authorization"))
|
|
if err != nil {
|
|
bearertoken.SetBearerInvalidToken(w, h.baseURL)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
accessToken, err := h.iam.OAuth2ServerService.LoadAccessToken(r.Context(), tokenValue)
|
|
if err != nil {
|
|
bearertoken.SetBearerInvalidToken(w, h.baseURL)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
ctx := oauth2.ContextWithAccessToken(r.Context(), accessToken)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (h *OAuth2Handler) endpoints() oauth2.Endpoints {
|
|
api := h.baseURL.String() + "/api/connect/v1"
|
|
|
|
return oauth2.Endpoints{
|
|
Authorization: uri.URI(api + "/oauth2/authorize"),
|
|
Token: uri.URI(api + "/oauth2/token"),
|
|
Userinfo: uri.URI(api + "/oauth2/userinfo"),
|
|
JWKS: uri.URI(api + "/oauth2/jwks"),
|
|
Registration: uri.URI(api + "/oauth2/register"),
|
|
Introspection: uri.URI(api + "/oauth2/introspect"),
|
|
Revocation: uri.URI(api + "/oauth2/revoke"),
|
|
DeviceAuthorization: uri.URI(api + "/oauth2/device"),
|
|
}
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
// DiscoveryHandler serves the OpenID Connect Discovery document.
|
|
// GET /.well-known/openid-configuration
|
|
func (h *OAuth2Handler) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
|
|
metadata := h.iam.OAuth2ServerMetadata(h.endpoints())
|
|
|
|
PublicCache(w, 1*time.Hour)
|
|
httpserver.RenderJSON(w, http.StatusOK, metadata)
|
|
}
|
|
|
|
// JWKSHandler serves the JSON Web Key Set.
|
|
// GET /oauth2/jwks
|
|
func (h *OAuth2Handler) JWKSHandler(w http.ResponseWriter, r *http.Request) {
|
|
jwks := h.iam.OAuth2ServerService.JWKS()
|
|
|
|
PublicCache(w, 1*time.Hour)
|
|
httpserver.RenderJSON(w, http.StatusOK, jwks)
|
|
}
|
|
|
|
// AuthorizeHandler handles the authorization endpoint.
|
|
// GET /oauth2/authorize
|
|
func (h *OAuth2Handler) AuthorizeHandler(w http.ResponseWriter, r *http.Request) {
|
|
identity := authn.IdentityFromContext(r.Context())
|
|
if identity == nil {
|
|
continueURL := h.baseURL.WithPath("/api/connect/v1/oauth2/authorize").
|
|
WithQueryValues(r.URL.Query()).
|
|
MustString()
|
|
loginURL := h.baseURL.WithPath("/auth/login").
|
|
WithQuery("continue", continueURL).
|
|
MustString()
|
|
http.Redirect(w, r, loginURL, http.StatusFound)
|
|
|
|
return
|
|
}
|
|
|
|
var in types.OAuth2AuthorizeInput
|
|
if err := in.DecodeQuery(r.URL.Query()); err != nil {
|
|
h.handleAuthorizeError(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)), "", "")
|
|
return
|
|
}
|
|
|
|
session := authn.SessionFromContext(r.Context())
|
|
|
|
authTime := time.Now()
|
|
if session != nil {
|
|
authTime = session.CreatedAt
|
|
}
|
|
|
|
code, err := h.iam.OAuth2ServerService.Authorize(
|
|
r.Context(),
|
|
&oauth2.AuthorizeRequest{
|
|
IdentityID: identity.ID,
|
|
SessionID: session.ID,
|
|
ResponseType: in.ResponseType,
|
|
ClientIDRaw: in.ClientIDRaw,
|
|
RedirectURI: in.RedirectURI,
|
|
Scopes: in.Scopes,
|
|
CodeChallenge: in.CodeChallenge,
|
|
CodeChallengeMethod: in.CodeChallengeMethod,
|
|
Nonce: in.Nonce,
|
|
State: in.State,
|
|
AuthTime: authTime,
|
|
},
|
|
)
|
|
|
|
if consentErr, ok := errors.AsType[*oauth2.ConsentRequiredError](err); ok {
|
|
consentURL := h.baseURL.WithPath("/auth/consent").
|
|
WithQuery("consent_id", consentErr.ConsentID.String()).
|
|
MustString()
|
|
http.Redirect(w, r, consentURL, http.StatusFound)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
oauthErr := toOAuth2Error(err)
|
|
h.handleAuthorizeError(w, r, oauthErr, in.RedirectURI, in.State)
|
|
|
|
return
|
|
}
|
|
|
|
redirectWithCode(w, r, in.RedirectURI, code, in.State)
|
|
}
|
|
|
|
func (h *OAuth2Handler) TokenHandler(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid form data")))
|
|
return
|
|
}
|
|
|
|
var (
|
|
grantType coredata.OAuth2GrantType
|
|
value = r.FormValue("grant_type")
|
|
)
|
|
|
|
if err := grantType.UnmarshalText([]byte(value)); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrUnsupportedGrantType)
|
|
return
|
|
}
|
|
|
|
switch grantType {
|
|
case coredata.OAuth2GrantTypeAuthorizationCode:
|
|
h.handleAuthorizationCodeGrant(w, r)
|
|
case coredata.OAuth2GrantTypeRefreshToken:
|
|
h.handleRefreshTokenGrant(w, r)
|
|
case coredata.OAuth2GrantTypeDeviceCode:
|
|
h.handleDeviceCodeGrant(w, r)
|
|
default:
|
|
panic(fmt.Sprintf("unsupported grant type: %s", grantType))
|
|
}
|
|
}
|
|
|
|
func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request) {
|
|
client, ok := oauth2.ClientFromContext(r.Context())
|
|
if !ok {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
|
return
|
|
}
|
|
|
|
in := types.OAuth2IntrospectInput{}
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
result, err := h.iam.OAuth2ServerService.IntrospectToken(
|
|
r.Context(),
|
|
client.ID,
|
|
in.Token,
|
|
in.TokenTypeHint,
|
|
)
|
|
if err != nil || result == nil {
|
|
httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse())
|
|
return
|
|
}
|
|
|
|
httpserver.RenderJSON(w, http.StatusOK, types.ActiveIntrospectResponse(result))
|
|
}
|
|
|
|
func (h *OAuth2Handler) RevokeHandler(w http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
client, _ = oauth2.ClientFromContext(r.Context())
|
|
in = types.OAuth2RevokeInput{}
|
|
)
|
|
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
if err := h.iam.OAuth2ServerService.RevokeToken(
|
|
r.Context(),
|
|
client.ID,
|
|
in.Token,
|
|
in.TokenTypeHint,
|
|
); err != nil {
|
|
h.logger.ErrorCtx(r.Context(), "cannot revoke token", log.Error(err))
|
|
w.Header().Set("Retry-After", "30")
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// DeviceAuthHandler handles the device authorization endpoint (RFC 8628).
|
|
// POST /oauth2/device
|
|
func (h *OAuth2Handler) DeviceAuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
in := types.OAuth2DeviceAuthInput{}
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
deviceCodeValue, dc, err := h.iam.OAuth2ServerService.CreateDeviceCode(
|
|
r.Context(),
|
|
in.ClientID,
|
|
in.Scopes,
|
|
)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, err)
|
|
return
|
|
}
|
|
|
|
verificationURI := uri.URI(h.baseURL.WithPath("/auth/device").MustString())
|
|
verificationURIComplete := uri.URI(
|
|
h.baseURL.WithPath("/auth/device").
|
|
WithQuery("user_code", string(dc.UserCode)).
|
|
MustString(),
|
|
)
|
|
|
|
httpserver.RenderJSON(
|
|
w,
|
|
http.StatusOK,
|
|
&types.OAuth2DeviceAuthResponse{
|
|
DeviceCode: deviceCodeValue,
|
|
UserCode: dc.UserCode.Format(),
|
|
VerificationURI: verificationURI,
|
|
VerificationURIComplete: verificationURIComplete,
|
|
ExpiresIn: int(time.Until(dc.ExpiresAt).Seconds()),
|
|
Interval: dc.PollInterval,
|
|
},
|
|
)
|
|
}
|
|
|
|
// RegisterHandler handles dynamic client registration (RFC 7591).
|
|
// POST /oauth2/register
|
|
func (h *OAuth2Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|
identity := authn.IdentityFromContext(r.Context())
|
|
|
|
var in types.OAuth2RegisterInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
h.renderOAuth2ErrorResponse(
|
|
w,
|
|
r,
|
|
oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithDescription("invalid JSON body")),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if len(in.GrantTypes) == 0 {
|
|
in.GrantTypes = []coredata.OAuth2GrantType{coredata.OAuth2GrantTypeAuthorizationCode}
|
|
}
|
|
|
|
if len(in.ResponseTypes) == 0 {
|
|
in.ResponseTypes = []coredata.OAuth2ResponseType{coredata.OAuth2ResponseTypeCode}
|
|
}
|
|
|
|
if in.TokenEndpointAuthMethod == "" {
|
|
in.TokenEndpointAuthMethod = coredata.OAuth2ClientTokenEndpointAuthMethodClientSecretBasic
|
|
}
|
|
|
|
if in.Visibility == "" {
|
|
in.Visibility = coredata.OAuth2ClientVisibilityPrivate
|
|
}
|
|
|
|
if len(in.Scopes) == 0 {
|
|
in.Scopes = coredata.OAuth2Scopes{
|
|
oauth2.ScopeOpenID,
|
|
oauth2.ScopeProfile,
|
|
oauth2.ScopeEmail,
|
|
}
|
|
}
|
|
|
|
clientID, clientSecret, err := h.iam.OAuth2ServerService.RegisterClient(
|
|
r.Context(),
|
|
&oauth2.RegisterClientRequest{
|
|
IdentityID: identity.ID,
|
|
OrganizationID: in.OrganizationID,
|
|
ClientName: in.ClientName,
|
|
Visibility: in.Visibility,
|
|
RedirectURIs: in.RedirectURIs,
|
|
GrantTypes: in.GrantTypes,
|
|
ResponseTypes: in.ResponseTypes,
|
|
TokenEndpointAuthMethod: in.TokenEndpointAuthMethod,
|
|
LogoURI: in.LogoURI,
|
|
ClientURI: in.ClientURI,
|
|
Contacts: in.Contacts,
|
|
Scopes: in.Scopes,
|
|
},
|
|
)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, err)
|
|
return
|
|
}
|
|
|
|
httpserver.RenderJSON(
|
|
w,
|
|
http.StatusCreated,
|
|
&types.OAuth2RegisterResponse{
|
|
ClientID: clientID.String(),
|
|
ClientSecret: clientSecret,
|
|
ClientName: in.ClientName,
|
|
Visibility: in.Visibility,
|
|
RedirectURIs: in.RedirectURIs,
|
|
GrantTypes: in.GrantTypes,
|
|
ResponseTypes: in.ResponseTypes,
|
|
TokenEndpointAuthMethod: in.TokenEndpointAuthMethod,
|
|
Scopes: in.Scopes,
|
|
},
|
|
)
|
|
}
|
|
|
|
// UserInfoHandler serves the OIDC UserInfo endpoint.
|
|
// GET /oauth2/userinfo
|
|
func (h *OAuth2Handler) UserInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
accessToken, ok := oauth2.AccessTokenFromContext(r.Context())
|
|
if !ok {
|
|
bearertoken.SetBearerInvalidToken(w, h.baseURL)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
claims, err := h.iam.OAuth2ServerService.UserInfo(
|
|
r.Context(),
|
|
accessToken.IdentityID,
|
|
accessToken.Scopes,
|
|
)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrServerError)
|
|
return
|
|
}
|
|
|
|
httpserver.RenderJSON(w, http.StatusOK, claims)
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
func (h *OAuth2Handler) authenticateClient(r *http.Request) (*coredata.OAuth2Client, error) {
|
|
if err := r.ParseForm(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var clientIDStr, clientSecret string
|
|
|
|
// Try HTTP Basic auth first.
|
|
if username, password, ok := r.BasicAuth(); ok {
|
|
clientIDStr = username
|
|
clientSecret = password
|
|
} else {
|
|
// Fall back to POST body.
|
|
clientIDStr = r.FormValue("client_id")
|
|
clientSecret = r.FormValue("client_secret")
|
|
}
|
|
|
|
if clientIDStr == "" {
|
|
return nil, oauth2.ErrInvalidClient
|
|
}
|
|
|
|
return h.iam.OAuth2ServerService.AuthenticateClient(r.Context(), clientIDStr, clientSecret)
|
|
}
|
|
|
|
func (h *OAuth2Handler) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) {
|
|
client, err := h.authenticateClient(r)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
|
return
|
|
}
|
|
|
|
var in types.OAuth2AuthorizationCodeGrantInput
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
result, err := h.iam.OAuth2ServerService.ExchangeAuthorizationCode(
|
|
r.Context(),
|
|
client,
|
|
in.Code,
|
|
in.RedirectURI,
|
|
in.CodeVerifier,
|
|
)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired code")))
|
|
return
|
|
}
|
|
|
|
NoCache(w)
|
|
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
|
|
}
|
|
|
|
func (h *OAuth2Handler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) {
|
|
client, err := h.authenticateClient(r)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.ErrInvalidClient)
|
|
return
|
|
}
|
|
|
|
var in types.OAuth2RefreshTokenGrantInput
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
result, err := h.iam.OAuth2ServerService.RefreshToken(r.Context(), client, in.RefreshToken)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidGrant, oauth2.WithDescription("invalid or expired refresh token")))
|
|
return
|
|
}
|
|
|
|
NoCache(w)
|
|
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
|
|
}
|
|
|
|
func (h *OAuth2Handler) handleDeviceCodeGrant(w http.ResponseWriter, r *http.Request) {
|
|
var in types.OAuth2DeviceCodeGrantInput
|
|
if err := in.DecodeForm(r); err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, oauth2.NewError(oauth2.ErrInvalidRequest, oauth2.WithError(err)))
|
|
return
|
|
}
|
|
|
|
result, err := h.iam.OAuth2ServerService.PollDeviceCode(
|
|
r.Context(),
|
|
in.ClientID,
|
|
in.DeviceCode,
|
|
)
|
|
if err != nil {
|
|
h.renderOAuth2ErrorResponse(w, r, err)
|
|
return
|
|
}
|
|
|
|
NoCache(w)
|
|
httpserver.RenderJSON(w, http.StatusOK, tokenResultToResponse(result))
|
|
}
|
|
|
|
func tokenResultToResponse(r *oauth2.TokenResult) *types.OAuth2TokenResponse {
|
|
return &types.OAuth2TokenResponse{
|
|
AccessToken: r.AccessToken,
|
|
TokenType: r.TokenType,
|
|
ExpiresIn: r.ExpiresIn,
|
|
RefreshToken: r.RefreshToken,
|
|
IDToken: r.IDToken,
|
|
Scope: r.Scope,
|
|
}
|
|
}
|
|
|
|
func redirectWithCode(w http.ResponseWriter, r *http.Request, redirectURI, code, state string) {
|
|
u, _ := url.Parse(redirectURI)
|
|
q := u.Query()
|
|
q.Set("code", code)
|
|
|
|
if state != "" {
|
|
q.Set("state", state)
|
|
}
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
|
}
|