14 files changed,
189 insertions(+),
46 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2025-09-18 14:12:53 +0300
Parent:
3f33118
jump to
M
api/paths/auth/oauth-provider-callback.yml
··· 28 28 type: string 29 29 30 30 responses: 31 - '200': 32 - description: OAuth login successful 33 - content: 34 - application/json: 31 + '302': 32 + description: Redirect to frontend with tokens 33 + headers: 34 + Location: 35 + description: Frontend URL with tokens or error as query params 35 36 schema: 36 - $ref: '../../components/schemas/JwtTokens.yml' 37 - 38 - # TODO: unimplemented 39 - # '302': 40 - # description: Redirect to frontend with tokens (alternative flow) 41 - # headers: 42 - # Location: 43 - # description: Frontend URL with tokens as query params or hash 44 - # schema: 45 - # type: string 46 - # example: "onasty.local/api/v1/auth/success?access=...&refresh=..." 37 + type: string 38 + example: "onasty.local/api/v1/auth/callback?access_token=...&refresh_token=...&error=..." 47 39 48 40 '400': 49 41 $ref: '../../components/responses/ErrorResponse.yml'
M
internal/config/config.go
··· 22 22 } 23 23 24 24 type Config struct { 25 - AppEnv Environment 26 - AppURL string 27 - NatsURL string 25 + AppEnv Environment 26 + AppURL string 27 + FrontendURL string 28 + NatsURL string 28 29 29 30 CORSAllowedOrigins []string 30 31 CORSMaxAge time.Duration ··· 79 80 func NewConfig() *Config { 80 81 once.Do(func() { 81 82 instance = &Config{ 82 - AppEnv: Environment(getenvOrDefault("APP_ENV", "debug")), 83 - AppURL: getenvOrDefault("APP_URL", ""), 84 - NatsURL: getenvOrDefault("NATS_URL", ""), 83 + AppEnv: Environment(getenvOrDefault("APP_ENV", "debug")), 84 + AppURL: getenvOrDefault("APP_URL", ""), 85 + FrontendURL: getenvOrDefault("FRONTEND_URL", ""), 86 + NatsURL: getenvOrDefault("NATS_URL", ""), 85 87 86 88 CORSAllowedOrigins: strings.Split(getenvOrDefault("CORS_ALLOWED_ORIGINS", "*"), ","), 87 89 CORSMaxAge: mustParseDuration(getenvOrDefault("CORS_MAX_AGE", "12h")),
M
internal/transport/http/apiv1/apiv1.go
··· 10 10 ) 11 11 12 12 type APIV1 struct { 13 - authsrv authsrv.AuthServicer 14 - usersrv usersrv.UserServicer 15 - notesrv notesrv.NoteServicer 13 + authsrv authsrv.AuthServicer 14 + usersrv usersrv.UserServicer 15 + notesrv notesrv.NoteServicer 16 + 17 + env config.Environment 16 18 slowRatelimitCfg ratelimit.Config 17 - env config.Environment 18 - domain string 19 + 20 + appURL string 21 + frontendURL string 19 22 } 20 23 21 24 func NewAPIV1( ··· 24 27 ns notesrv.NoteServicer, 25 28 slowRatelimitCfg ratelimit.Config, 26 29 env config.Environment, 27 - domain string, 30 + appURL string, 31 + frontendURL string, 28 32 ) *APIV1 { 29 33 return &APIV1{ 30 34 authsrv: as, ··· 32 36 notesrv: ns, 33 37 slowRatelimitCfg: slowRatelimitCfg, 34 38 env: env, 35 - domain: domain, 39 + appURL: appURL, 40 + frontendURL: frontendURL, 36 41 } 37 42 } 38 43
M
internal/transport/http/apiv1/auth.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "net/url" 5 6 "time" 6 7 7 8 "github.com/gin-gonic/gin" ··· 134 135 redirectInfo.State, 135 136 int(time.Minute.Seconds()), 136 137 "/", 137 - a.domain, 138 + a.appURL, 138 139 !a.env.IsDevMode(), 139 140 true, 140 141 ) ··· 143 144 } 144 145 145 146 func (a *APIV1) oauthCallbackHandler(c *gin.Context) { 146 - state := c.Query("state") 147 + redURL, err := url.Parse(a.frontendURL + "/oauth/callback") 148 + if err != nil { 149 + errorResponse(c, err) 150 + return 151 + } 152 + 147 153 storedState, err := c.Cookie(oatuhStateCookie) 148 - if err != nil || state != storedState { 149 - newError(c, http.StatusBadRequest, "invalid oauth state") 154 + if err != nil || c.Query("state") != storedState { 155 + a.oauthCallbackErrorResponse(c, redURL) 150 156 return 151 157 } 152 158 ··· 156 162 c.Query("code"), 157 163 ) 158 164 if err != nil { 159 - errorResponse(c, err) 165 + a.oauthCallbackErrorResponse(c, redURL) 160 166 return 161 167 } 162 168 163 - c.JSON(http.StatusOK, signInResponse{ 164 - AccessToken: tokens.Access, 165 - RefreshToken: tokens.Refresh, 166 - }) 169 + redURL.RawQuery = url.Values{ 170 + "access_token": {tokens.Access}, 171 + "refresh_token": {tokens.Refresh}, 172 + }.Encode() 173 + 174 + c.Redirect(http.StatusFound, redURL.String()) 175 +} 176 + 177 +func (a *APIV1) oauthCallbackErrorResponse(c *gin.Context, u *url.URL) { 178 + u.RawQuery = url.Values{"error": {"internal server error"}}.Encode() 179 + c.Redirect(http.StatusFound, u.String()) 167 180 }
M
internal/transport/http/http.go
··· 19 19 usersrv usersrv.UserServicer 20 20 notesrv notesrv.NoteServicer 21 21 22 - env config.Environment 23 - domain string 22 + env config.Environment 23 + appURL string 24 + frontendURL string 24 25 25 26 corsAllowedOrigins []string 26 27 corsMaxAge time.Duration ··· 33 34 us usersrv.UserServicer, 34 35 ns notesrv.NoteServicer, 35 36 env config.Environment, 36 - domain string, 37 + appURL, frontendURL string, 37 38 corsAllowedOrigins []string, 38 39 corsMaxAge time.Duration, 39 40 ratelimitCfg ratelimit.Config, ··· 44 45 usersrv: us, 45 46 notesrv: ns, 46 47 env: env, 47 - domain: domain, 48 + appURL: appURL, 49 + frontendURL: frontendURL, 48 50 corsAllowedOrigins: corsAllowedOrigins, 49 51 corsMaxAge: corsMaxAge, 50 52 ratelimitCfg: ratelimitCfg, ··· 66 68 { 67 69 api.GET("/ping", t.pingHandler) 68 70 apiv1. 69 - NewAPIV1(t.authsrv, t.usersrv, t.notesrv, t.slowRatelimitCfg, t.env, t.domain). 71 + NewAPIV1( 72 + t.authsrv, 73 + t.usersrv, 74 + t.notesrv, 75 + t.slowRatelimitCfg, 76 + t.env, 77 + t.appURL, 78 + t.frontendURL, 79 + ). 70 80 Routes(api.Group("/v1")) 71 81 } 72 82
M
web/src/Components/Form.elm
··· 1 -module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, submitButton) 1 +module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, oauthButton, submitButton) 2 2 3 3 import Html as H exposing (Html) 4 4 import Html.Attributes as A ··· 90 90 | Secondary CanBeClicked 91 91 | SecondaryDisabled CanBeClicked 92 92 | SecondaryDanger 93 + | OauthButton CanBeClicked 93 94 94 95 95 96 button : { text : String, disabled : Bool, onClick : msg, style : ButtonStyle } -> Html msg ··· 113 114 [ H.text opts.text ] 114 115 115 116 117 +oauthButton : { text : String, disabled : Bool, onClick : msg, iconURL : String } -> Html msg 118 +oauthButton { text, disabled, onClick, iconURL } = 119 + H.button 120 + [ A.type_ "button" 121 + , A.class (buttonStyleToClass (OauthButton (not disabled)) "mt-2") 122 + , A.disabled disabled 123 + , E.onClick onClick 124 + ] 125 + [ H.div [ A.class "flex" ] 126 + [ H.img [ A.class "w-5 h-5 mr-3", A.src iconURL ] [] 127 + , H.text text 128 + ] 129 + ] 130 + 131 + 116 132 buttonStyleToClass : ButtonStyle -> String -> String 117 133 buttonStyleToClass style appendClasses = 118 134 case style of ··· 142 158 appendClasses 143 159 "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-400 cursor-not-allowed" 144 160 "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-700 hover:bg-gray-50" 161 + 162 + OauthButton canBeClicked -> 163 + getButtonClasses canBeClicked 164 + appendClasses 165 + "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700" 166 + "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 opacity-50 cursor-not-allowed" 145 167 146 168 147 169 getButtonClasses : Bool -> String -> String -> String -> String
M
web/src/Pages/Auth.elm
··· 88 88 | UserChangedFormVariant FormVariant 89 89 | UserClickedSubmit 90 90 | UserClickedResendActivationEmail 91 + | UserClickedOAuth Provider 91 92 | ApiSignInResponded (Result Api.Error Credentials) 92 93 | ApiSignUpResponded (Result Api.Error ()) 93 94 | ApiForgotPasswordResponded (Result Api.Error ()) 94 95 | ApiSetNewPasswordResponded (Result Api.Error ()) 95 96 | ApiResendVerificationEmail (Result Api.Error ()) 97 + 98 + 99 +type Provider 100 + = Github 101 + | Google 96 102 97 103 98 104 type Field ··· 149 155 Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password } 150 156 ) 151 157 158 + UserClickedOAuth provider -> 159 + case provider of 160 + Github -> 161 + ( model, Effect.loadExternalUrl "/api/v1/oauth/github" ) 162 + 163 + Google -> 164 + ( model, Effect.loadExternalUrl "/api/v1/oauth/google" ) 165 + 152 166 UserClickedResendActivationEmail -> 153 167 ( { model | lastClicked = model.now } 154 168 , Api.Auth.resendVerificationEmail ··· 223 237 , body = 224 238 [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ] 225 239 [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ] 226 - -- TODO: add oauth buttons 227 240 [ viewBanner model 228 241 , viewBoxHeader model.formVariant 229 242 , H.div [ A.class "px-6 pb-6 space-y-4" ] 230 243 [ viewChangeVariant model.formVariant 244 + , viewOauthButtons model.isSubmittingForm 231 245 , H.div [ A.class "border-t border-gray-200" ] [] 232 246 , viewForm model 233 247 ] ··· 325 339 , disabled = variant == SignUp 326 340 , style = Components.Form.Primary (variant == SignUp) 327 341 , onClick = UserChangedFormVariant SignUp 342 + } 343 + ] 344 + 345 + 346 +viewOauthButtons : Bool -> Html Msg 347 +viewOauthButtons isSubmittingForm = 348 + H.div [] 349 + [ Components.Form.oauthButton 350 + { text = "Sign in with Google" 351 + , onClick = UserClickedOAuth Google 352 + , disabled = isSubmittingForm 353 + , iconURL = "/static/google.svg" 354 + } 355 + , Components.Form.oauthButton 356 + { text = "Sign in with GitHub" 357 + , onClick = UserClickedOAuth Github 358 + , disabled = isSubmittingForm 359 + , iconURL = "/static/github.svg" 328 360 } 329 361 ] 330 362
A
web/src/Pages/Oauth/Callback.elm
··· 1 +module Pages.Oauth.Callback exposing (Model, Msg, page) 2 + 3 +import Components.Box 4 +import Components.Utils 5 +import Dict exposing (Dict) 6 +import Effect exposing (Effect) 7 +import Layouts 8 +import Page exposing (Page) 9 +import Route exposing (Route) 10 +import Shared 11 +import View exposing (View) 12 + 13 + 14 +type alias Msg = 15 + {} 16 + 17 + 18 +page : Shared.Model -> Route () -> Page Model Msg 19 +page _ route = 20 + Page.new 21 + { init = init route.query 22 + , update = \_ m -> ( m, Effect.none ) 23 + , subscriptions = \_ -> Sub.none 24 + , view = view 25 + } 26 + |> Page.withLayout (\_ -> Layouts.Header {}) 27 + 28 + 29 +type alias Model = 30 + { error : String } 31 + 32 + 33 +init : Dict String String -> () -> ( Model, Effect Msg ) 34 +init query () = 35 + case 36 + ( Dict.get "access_token" query 37 + , Dict.get "refresh_token" query 38 + , Dict.get "error" query 39 + ) 40 + of 41 + ( Just at, Just rt, _ ) -> 42 + ( { error = "" }, Effect.signin { accessToken = at, refreshToken = rt } ) 43 + 44 + ( _, _, Just error ) -> 45 + ( { error = error }, Effect.none ) 46 + 47 + _ -> 48 + ( { error = "Invalid server response" }, Effect.none ) 49 + 50 + 51 +view : Model -> View msg 52 +view model = 53 + { title = "Oauth" 54 + , body = 55 + [ Components.Utils.commonContainer 56 + [ Components.Box.error model.error ] 57 + ] 58 + }
A
web/static/github.svg
··· 1 +<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
A
web/static/google.svg
··· 1 +<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>