feat: fb and google oauth
This commit is contained in:
@@ -53,3 +53,8 @@ COOKIE_SECURE=false
|
|||||||
|
|
||||||
# Frontend (CORS + email link base)
|
# Frontend (CORS + email link base)
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
GOOGLE_OAUTH_CLIENT_ID=
|
||||||
|
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||||
|
FACEBOOK_OAUTH_CLIENT_ID=
|
||||||
|
FACEBOOK_OAUTH_CLIENT_SECRET=
|
||||||
@@ -3,23 +3,26 @@ module github.com/prabhupadasays/spsays-bff
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.9
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/redis/go-redis/v9 v9.19.0
|
github.com/redis/go-redis/v9 v9.19.0
|
||||||
github.com/typesense/typesense-go/v2 v2.0.0
|
github.com/typesense/typesense-go/v2 v2.0.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.2.1
|
go.mongodb.org/mongo-driver/v2 v2.2.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.52.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||||
@@ -27,14 +30,8 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
|
||||||
github.com/aws/smithy-go v1.26.0 // indirect
|
github.com/aws/smithy-go v1.26.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/uuid v1.5.0 // indirect
|
github.com/google/uuid v1.5.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
@@ -49,7 +46,6 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
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/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||||
@@ -5,12 +7,8 @@ github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGAL
|
|||||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||||
@@ -27,14 +25,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDb
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
|
||||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
@@ -117,18 +107,16 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
|||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-20220722155255-886fb9371eb4/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -136,17 +124,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220520151302-bc2c85ada10a/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||||
|
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -65,6 +65,18 @@ type Config struct {
|
|||||||
|
|
||||||
// Frontend
|
// Frontend
|
||||||
FrontendURL string // for CORS + reset/verify links in emails
|
FrontendURL string // for CORS + reset/verify links in emails
|
||||||
|
|
||||||
|
// BFF public URL — the externally-reachable origin of this server. Used
|
||||||
|
// to build the OAuth redirect_uri the provider sees (must match the
|
||||||
|
// allow-list configured in the provider's console). In dev this is
|
||||||
|
// usually http://localhost:8080.
|
||||||
|
BFFPublicURL string
|
||||||
|
|
||||||
|
// OAuth providers — empty creds disable the corresponding provider.
|
||||||
|
GoogleOAuthClientID string
|
||||||
|
GoogleOAuthClientSecret string
|
||||||
|
FacebookOAuthClientID string
|
||||||
|
FacebookOAuthClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables (with .env fallback).
|
// Load reads configuration from environment variables (with .env fallback).
|
||||||
@@ -116,6 +128,13 @@ func Load() (*Config, error) {
|
|||||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||||
|
|
||||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||||
|
|
||||||
|
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||||
|
|
||||||
|
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
|
||||||
|
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
|
||||||
|
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
|
||||||
|
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.MongoURI == "" {
|
if cfg.MongoURI == "" {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stateCookieName is the short-lived cookie that pairs with the state query
|
||||||
|
// param so callback can verify "this redirect was started by us." Scoped to
|
||||||
|
// /api/v1/auth/oauth so it never leaks into other handlers.
|
||||||
|
const stateCookieName = "spsays_oauth_state"
|
||||||
|
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
|
||||||
|
|
||||||
|
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
|
||||||
|
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
|
||||||
|
type OAuthHandler struct {
|
||||||
|
svc *service.AuthService
|
||||||
|
cookies auth.CookieOptions
|
||||||
|
providers oauth.Registry
|
||||||
|
stateSecret []byte // HMAC key for signing state — derived from JWT secret
|
||||||
|
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
|
||||||
|
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthHandler(
|
||||||
|
svc *service.AuthService,
|
||||||
|
cookies auth.CookieOptions,
|
||||||
|
providers oauth.Registry,
|
||||||
|
stateSecret []byte,
|
||||||
|
bffPublicURL, frontendURL string,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *OAuthHandler {
|
||||||
|
return &OAuthHandler{
|
||||||
|
svc: svc,
|
||||||
|
cookies: cookies,
|
||||||
|
providers: providers,
|
||||||
|
stateSecret: stateSecret,
|
||||||
|
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
|
||||||
|
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start kicks off the OAuth round-trip. It generates a signed state value,
|
||||||
|
// stores it in a short-lived cookie, and 302s the browser to the provider's
|
||||||
|
// AuthCodeURL. `next` query param carries the post-login redirect path on
|
||||||
|
// the frontend (defaults to "/u/me").
|
||||||
|
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerName := chi.URLParam(r, "provider")
|
||||||
|
prov, err := h.providers.Get(providerName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||||
|
|
||||||
|
state, err := oauth.EncodeState(h.stateSecret, providerName, next)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("oauth encode state", zap.Error(err))
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State cookie matches the auth cookie's SameSite/Secure profile so the
|
||||||
|
// browser sends it back on the callback redirect. Path-scoped so it
|
||||||
|
// doesn't ride along on unrelated requests.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: state,
|
||||||
|
Path: "/api/v1/auth/oauth",
|
||||||
|
Domain: h.cookies.Domain,
|
||||||
|
MaxAge: stateCookieMaxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookies.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
cfg := prov.Config(h.callbackURL(providerName))
|
||||||
|
authURL := cfg.AuthCodeURL(state)
|
||||||
|
http.Redirect(w, r, authURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback finishes the round-trip. Verifies state, exchanges the code,
|
||||||
|
// fetches the profile, signs the user in, sets the session cookie, and
|
||||||
|
// redirects back to the frontend `next` path.
|
||||||
|
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providerName := chi.URLParam(r, "provider")
|
||||||
|
prov, err := h.providers.Get(providerName)
|
||||||
|
if err != nil {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider returned an error (user denied, etc.).
|
||||||
|
if e := r.URL.Query().Get("error"); e != "" {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State must be present in BOTH cookie and query param, and they must
|
||||||
|
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
|
||||||
|
// query alone → an attacker could craft any signed state).
|
||||||
|
cookie, err := r.Cookie(stateCookieName)
|
||||||
|
if err != nil {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=missing_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateQ := r.URL.Query().Get("state")
|
||||||
|
if stateQ == "" || stateQ != cookie.Value {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Clear the state cookie regardless of what happens next.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: stateCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/api/v1/auth/oauth",
|
||||||
|
Domain: h.cookies.Domain,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.cookies.Secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
stateProvider, next, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||||
|
if err != nil || stateProvider != providerName {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=missing_code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := prov.Config(h.callbackURL(providerName))
|
||||||
|
tok, err := cfg.Exchange(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prof, err := prov.FetchProfile(r.Context(), tok)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||||
|
Provider: prof.Provider,
|
||||||
|
ProviderUserID: prof.ProviderUserID,
|
||||||
|
Email: prof.Email,
|
||||||
|
EmailVerified: prof.EmailVerified,
|
||||||
|
Name: prof.Name,
|
||||||
|
AvatarURL: prof.AvatarURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrOAuthEmailMissing):
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=email_required")
|
||||||
|
case errors.Is(err, service.ErrOAuthUnavailable):
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=unavailable")
|
||||||
|
default:
|
||||||
|
h.logger.Error("oauth sign-in", zap.Error(err))
|
||||||
|
h.redirectErr(w, r, "/login?oauth_error=server")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||||
|
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||||
|
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||||
|
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectErr sends the user back to the frontend with a single error query
|
||||||
|
// param so the login page can render something meaningful. No internal details
|
||||||
|
// leak through this path.
|
||||||
|
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
|
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
|
||||||
|
// containing a scheme, host, or whitespace is dropped to the default. This is
|
||||||
|
// the same anti-open-redirect treatment we apply on the share-shortener.
|
||||||
|
func sanitizeNext(raw string) string {
|
||||||
|
const def = "/u/me"
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(raw, " \t\r\n") {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if len(raw) > 512 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
+19
-2
@@ -5,7 +5,9 @@ import "time"
|
|||||||
// User represents a row in the `users` table.
|
// User represents a row in the `users` table.
|
||||||
//
|
//
|
||||||
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
||||||
// even if a caller accidentally passes a User through response.JSON.
|
// even if a caller accidentally passes a User through response.JSON. It's a
|
||||||
|
// pointer because OAuth-only users have no password — *PasswordHash == nil
|
||||||
|
// means "this account can only sign in via a linked OAuth provider".
|
||||||
//
|
//
|
||||||
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
||||||
// when the user has not yet verified, instead of an omitted field — the
|
// when the user has not yet verified, instead of an omitted field — the
|
||||||
@@ -19,7 +21,7 @@ type User struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Plan string `json:"plan"` // "free" | "premium"
|
Plan string `json:"plan"` // "free" | "premium"
|
||||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash *string `json:"-"`
|
||||||
TokenVersion int `json:"-"`
|
TokenVersion int `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -42,6 +44,21 @@ const (
|
|||||||
PurposeEmailVerification = "email_verification"
|
PurposeEmailVerification = "email_verification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
||||||
|
// per (provider, provider_user_id). A single user can have several linked
|
||||||
|
// providers.
|
||||||
|
type OAuthAccount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
ProviderUserID string `json:"provider_user_id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Name *string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// ShortURL represents a shortened URL.
|
// ShortURL represents a shortened URL.
|
||||||
type ShortURL struct {
|
type ShortURL struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
|
||||||
|
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
|
||||||
|
|
||||||
|
// OAuthAccountRepo manages provider linkage rows.
|
||||||
|
type OAuthAccountRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
|
||||||
|
return &OAuthAccountRepo{pg: pg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
|
||||||
|
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
|
||||||
|
// this provider identity has been seen before and we just log the user in.
|
||||||
|
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
|
||||||
|
FROM oauth_accounts
|
||||||
|
WHERE provider = $1 AND provider_user_id = $2
|
||||||
|
LIMIT 1`
|
||||||
|
a := &model.OAuthAccount{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
|
||||||
|
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||||
|
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrOAuthAccountNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("find oauth account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link inserts a new oauth_accounts row tying a provider identity to an
|
||||||
|
// existing user. Idempotent: a second call for the same (provider, sub)
|
||||||
|
// returns the existing row instead of erroring on the unique constraint.
|
||||||
|
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (provider, provider_user_id) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
|
||||||
|
a := &model.OAuthAccount{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
|
||||||
|
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||||
|
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("link oauth account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
@@ -62,6 +62,32 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
|
|||||||
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
|
||||||
|
// sign-in flow when there's no matching email yet. Same handle-collision
|
||||||
|
// retry contract as Create.
|
||||||
|
func (r *UserRepo) CreateOAuth(ctx context.Context, email, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
||||||
|
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
|
email_verified_at, token_version, created_at, updated_at`
|
||||||
|
u := &model.User{}
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, q,
|
||||||
|
strings.ToLower(strings.TrimSpace(email)), name, handle, avatarURL, emailVerified,
|
||||||
|
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||||
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err, "users_handle_key") {
|
||||||
|
return nil, ErrHandleTaken
|
||||||
|
}
|
||||||
|
if isUniqueViolation(err, "users_email_key") {
|
||||||
|
return nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FindByID returns the user with the given id, or ErrUserNotFound.
|
// FindByID returns the user with the given id, or ErrUserNotFound.
|
||||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,7 @@ type Server struct {
|
|||||||
searchH *handler.SearchHandler
|
searchH *handler.SearchHandler
|
||||||
shortURLH *handler.ShortURLHandler
|
shortURLH *handler.ShortURLHandler
|
||||||
authH *handler.AuthHandler
|
authH *handler.AuthHandler
|
||||||
|
oauthH *handler.OAuthHandler
|
||||||
userH *handler.UserHandler
|
userH *handler.UserHandler
|
||||||
postH *handler.PostHandler
|
postH *handler.PostHandler
|
||||||
bookmarkH *handler.BookmarkHandler
|
bookmarkH *handler.BookmarkHandler
|
||||||
@@ -121,6 +123,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
|
|
||||||
userRepo := repository.NewUserRepo(s.pg)
|
userRepo := repository.NewUserRepo(s.pg)
|
||||||
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||||
|
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
||||||
|
|
||||||
r2, err := repository.NewR2(repository.R2Config{
|
r2, err := repository.NewR2(repository.R2Config{
|
||||||
AccountID: cfg.R2AccountID,
|
AccountID: cfg.R2AccountID,
|
||||||
@@ -145,7 +148,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
From: cfg.SMTPFrom,
|
From: cfg.SMTPFrom,
|
||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||||
userSvc := service.NewUserService(userRepo, r2)
|
userSvc := service.NewUserService(userRepo, r2)
|
||||||
|
|
||||||
// Social repositories + services share the same Postgres pool and R2 client.
|
// Social repositories + services share the same Postgres pool and R2 client.
|
||||||
@@ -162,6 +165,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
s.userRepo = userRepo
|
s.userRepo = userRepo
|
||||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||||
s.userH = handler.NewUserHandler(userSvc)
|
s.userH = handler.NewUserHandler(userSvc)
|
||||||
|
|
||||||
|
// OAuth — wired only when at least one provider is configured. The
|
||||||
|
// registry skips providers with missing creds, so partial configs (e.g.
|
||||||
|
// Google only, no Facebook) work out of the box.
|
||||||
|
providers := oauth.NewRegistry(oauth.Credentials{
|
||||||
|
GoogleClientID: cfg.GoogleOAuthClientID,
|
||||||
|
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
|
||||||
|
FacebookClientID: cfg.FacebookOAuthClientID,
|
||||||
|
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
|
||||||
|
})
|
||||||
|
if len(providers) > 0 {
|
||||||
|
s.oauthH = handler.NewOAuthHandler(
|
||||||
|
authSvc, cookieOpts, providers,
|
||||||
|
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
||||||
|
}
|
||||||
s.postH = handler.NewPostHandler(postSvc)
|
s.postH = handler.NewPostHandler(postSvc)
|
||||||
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||||
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||||
@@ -486,6 +508,15 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/reset-password", s.authH.ResetPassword)
|
r.Post("/reset-password", s.authH.ResetPassword)
|
||||||
r.Post("/verify-email", s.authH.VerifyEmail)
|
r.Post("/verify-email", s.authH.VerifyEmail)
|
||||||
|
|
||||||
|
// OAuth (Google, Facebook, …). GET endpoints because the browser
|
||||||
|
// navigates directly to /start (top-level redirect) and the
|
||||||
|
// provider redirects back to /callback. Both flows set cookies
|
||||||
|
// and 302 — they never return JSON.
|
||||||
|
if s.oauthH != nil {
|
||||||
|
r.Get("/oauth/{provider}/start", s.oauthH.Start)
|
||||||
|
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
|
||||||
|
}
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/me", s.authH.Me)
|
r.Get("/me", s.authH.Me)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ var (
|
|||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
tokens *repository.AuthTokenRepo
|
tokens *repository.AuthTokenRepo
|
||||||
|
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
|
||||||
jwt *auth.Issuer
|
jwt *auth.Issuer
|
||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
@@ -49,10 +50,12 @@ type AuthService struct {
|
|||||||
EmailVerifyTTL time.Duration
|
EmailVerifyTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService wires the dependencies.
|
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||||
|
// the OAuth flows aren't wired (handler then returns 503).
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
tokens *repository.AuthTokenRepo,
|
tokens *repository.AuthTokenRepo,
|
||||||
|
oauthRepo *repository.OAuthAccountRepo,
|
||||||
jwt *auth.Issuer,
|
jwt *auth.Issuer,
|
||||||
mail mailer.Mailer,
|
mail mailer.Mailer,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
@@ -61,6 +64,7 @@ func NewAuthService(
|
|||||||
return &AuthService{
|
return &AuthService{
|
||||||
users: users,
|
users: users,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
|
oauth: oauthRepo,
|
||||||
jwt: jwt,
|
jwt: jwt,
|
||||||
mail: mail,
|
mail: mail,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -136,12 +140,135 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
// OAuth-only accounts have no password hash. Treat them as a credential
|
||||||
|
// mismatch so we don't reveal that an OAuth user exists for this email.
|
||||||
|
if u.PasswordHash == nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
return s.issueSession(u)
|
return s.issueSession(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||||
|
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
|
||||||
|
// layer doesn't import the OAuth package (which would create a layering
|
||||||
|
// cycle once handlers compose both).
|
||||||
|
type OAuthProfile struct {
|
||||||
|
Provider string
|
||||||
|
ProviderUserID string
|
||||||
|
Email string
|
||||||
|
EmailVerified bool
|
||||||
|
Name string
|
||||||
|
AvatarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did
|
||||||
|
// not give us an email and the account has no linkage we could match on.
|
||||||
|
// Without an email we can't safely create a new user (since later sign-ins
|
||||||
|
// via email/password would have no way to converge with this account).
|
||||||
|
var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email")
|
||||||
|
|
||||||
|
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
|
||||||
|
// startup). Handler maps to 503.
|
||||||
|
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||||
|
|
||||||
|
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||||
|
//
|
||||||
|
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||||
|
// 2. Existing user with same email → link this provider to them, log in.
|
||||||
|
// 3. Otherwise create a new user (no password, email_verified iff provider
|
||||||
|
// said so) and link.
|
||||||
|
//
|
||||||
|
// In every case the resulting user gets a fresh JWT just like LogIn.
|
||||||
|
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
|
||||||
|
if s.oauth == nil {
|
||||||
|
return nil, ErrOAuthUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Known linkage.
|
||||||
|
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||||
|
u, err := s.users.FindByID(ctx, a.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Refresh denormalised fields on the linkage row (display name /
|
||||||
|
// avatar may have changed at the provider).
|
||||||
|
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return s.issueSession(u)
|
||||||
|
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// No linkage yet — we need at least an email to safely converge with an
|
||||||
|
// existing account or create a new one.
|
||||||
|
if prof.Email == "" {
|
||||||
|
return nil, ErrOAuthEmailMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Existing user with the same email → link.
|
||||||
|
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
|
||||||
|
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u)
|
||||||
|
} else if !errors.Is(err, repository.ErrUserNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Brand new user. Reuse the handle-collision retry loop.
|
||||||
|
displayName := prof.Name
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = strings.SplitN(prof.Email, "@", 2)[0]
|
||||||
|
}
|
||||||
|
base := slugForHandle(prof.Email)
|
||||||
|
var u *model.User
|
||||||
|
var err error
|
||||||
|
for attempt := 0; attempt < 8; attempt++ {
|
||||||
|
handle := base
|
||||||
|
if attempt > 0 {
|
||||||
|
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||||
|
}
|
||||||
|
u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), prof.EmailVerified)
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrEmailTaken) {
|
||||||
|
// Race: someone else just claimed this email. Fall back to step 2.
|
||||||
|
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
|
||||||
|
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||||
|
return s.issueSession(u2)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||||
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.issueSession(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optStr returns nil for empty input, &s otherwise — used to populate
|
||||||
|
// nullable text columns from possibly-empty profile fields.
|
||||||
|
func optStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
// Me looks up a user by id and re-verifies the JWT's token_version against
|
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||||
// the stored value. Mismatch means the user has logged-out-everywhere or
|
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||||
// changed password since this JWT was issued → reject.
|
// changed password since this JWT was issued → reject.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS oauth_accounts;
|
||||||
|
-- Note: re-imposing NOT NULL on users.password_hash would require backfilling
|
||||||
|
-- any OAuth-only users with a dummy hash; left as a manual step.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- OAuth account linkage.
|
||||||
|
--
|
||||||
|
-- One row per (provider, provider_user_id). A single user can have several
|
||||||
|
-- providers linked (e.g. signed up with email + later linked Google). The
|
||||||
|
-- provider's `sub` (Google) / `id` (Facebook) is the stable identity — email
|
||||||
|
-- is captured for display but is NOT the join key, since providers can let
|
||||||
|
-- users change the email on the account.
|
||||||
|
--
|
||||||
|
-- When a callback fires:
|
||||||
|
-- 1. Look up (provider, provider_user_id) — found → log that user in.
|
||||||
|
-- 2. Otherwise look up users.email — found → link new oauth_accounts row
|
||||||
|
-- to the existing user and log them in.
|
||||||
|
-- 3. Otherwise create a new user (password_hash NULL) + an oauth_accounts
|
||||||
|
-- row, log them in.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider VARCHAR(32) NOT NULL, -- "google" | "facebook"
|
||||||
|
provider_user_id TEXT NOT NULL, -- sub (Google) / id (Facebook)
|
||||||
|
email CITEXT,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (provider, provider_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_accounts_user_id_idx ON oauth_accounts (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_accounts_provider_idx ON oauth_accounts (provider);
|
||||||
|
|
||||||
|
-- Password hash must be optional now: OAuth-only users have no password.
|
||||||
|
-- Existing email/password users keep their hash; new OAuth users get NULL
|
||||||
|
-- and can set a password later via the forgot-password flow if they want
|
||||||
|
-- a fallback login method.
|
||||||
|
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Package oauth wraps the third-party sign-in providers used by the BFF.
|
||||||
|
//
|
||||||
|
// Design: each provider is a small struct that knows three things —
|
||||||
|
// - its golang.org/x/oauth2.Config (client id / secret / scopes / endpoint)
|
||||||
|
// - how to fetch a normalised Profile from an access token
|
||||||
|
// - its registry name ("google", "facebook")
|
||||||
|
//
|
||||||
|
// The handler layer is provider-agnostic: it picks a Registry entry by URL
|
||||||
|
// param, generates state, redirects to AuthCodeURL, then on callback
|
||||||
|
// exchanges the code and calls FetchProfile. The service layer then takes
|
||||||
|
// the Profile and turns it into a session (find-or-create user + JWT).
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
googleEndpoint "golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile is the normalised shape we extract from every provider.
|
||||||
|
//
|
||||||
|
// Email may be empty when the user denied the email scope (rare on Google,
|
||||||
|
// possible on Facebook accounts with no verified email). Callers must treat
|
||||||
|
// an empty email as a hard error — we use email as a fallback link key.
|
||||||
|
type Profile struct {
|
||||||
|
Provider string // "google" | "facebook"
|
||||||
|
ProviderUserID string // sub (Google) / id (Facebook)
|
||||||
|
Email string // lowercased
|
||||||
|
EmailVerified bool // Google sets this; Facebook does not — default true
|
||||||
|
Name string // display name, may be empty
|
||||||
|
AvatarURL string // absolute URL, may be empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is the per-vendor adapter. Stateless after construction.
|
||||||
|
type Provider interface {
|
||||||
|
Name() string
|
||||||
|
Config(redirectURL string) *oauth2.Config
|
||||||
|
FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry maps provider name → Provider. Built from config at server start;
|
||||||
|
// only providers with both client ID and secret set become available.
|
||||||
|
type Registry map[string]Provider
|
||||||
|
|
||||||
|
// NewRegistry builds the live registry. Missing creds for a provider simply
|
||||||
|
// omit it — the start handler returns 404 for unconfigured providers.
|
||||||
|
func NewRegistry(cfg Credentials) Registry {
|
||||||
|
r := Registry{}
|
||||||
|
if cfg.GoogleClientID != "" && cfg.GoogleClientSecret != "" {
|
||||||
|
r["google"] = &google{id: cfg.GoogleClientID, secret: cfg.GoogleClientSecret}
|
||||||
|
}
|
||||||
|
if cfg.FacebookClientID != "" && cfg.FacebookClientSecret != "" {
|
||||||
|
r["facebook"] = &facebook{id: cfg.FacebookClientID, secret: cfg.FacebookClientSecret}
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials is the subset of app config the registry needs.
|
||||||
|
type Credentials struct {
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
FacebookClientID string
|
||||||
|
FacebookClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has reports whether the registry knows the named provider.
|
||||||
|
func (r Registry) Has(name string) bool { _, ok := r[name]; return ok }
|
||||||
|
|
||||||
|
// Get returns the provider, or ErrUnknownProvider.
|
||||||
|
func (r Registry) Get(name string) (Provider, error) {
|
||||||
|
p, ok := r[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnknownProvider
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnknownProvider is returned when the URL param doesn't match any
|
||||||
|
// configured provider.
|
||||||
|
var ErrUnknownProvider = errors.New("unknown oauth provider")
|
||||||
|
|
||||||
|
// --- Google ---
|
||||||
|
|
||||||
|
type google struct{ id, secret string }
|
||||||
|
|
||||||
|
func (g *google) Name() string { return "google" }
|
||||||
|
|
||||||
|
func (g *google) Config(redirectURL string) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: g.id,
|
||||||
|
ClientSecret: g.secret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"openid", "email", "profile"},
|
||||||
|
Endpoint: googleEndpoint.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleUserinfo is the subset of the v3 userinfo response we read. The
|
||||||
|
// full payload has more fields (locale, given_name, family_name) we don't
|
||||||
|
// currently need.
|
||||||
|
type googleUserinfo struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *google) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
|
||||||
|
const url = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
|
body, err := httpGet(ctx, tok, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("google userinfo: %w", err)
|
||||||
|
}
|
||||||
|
var u googleUserinfo
|
||||||
|
if err := json.Unmarshal(body, &u); err != nil {
|
||||||
|
return nil, fmt.Errorf("google userinfo decode: %w", err)
|
||||||
|
}
|
||||||
|
if u.Sub == "" {
|
||||||
|
return nil, fmt.Errorf("google userinfo: empty sub")
|
||||||
|
}
|
||||||
|
return &Profile{
|
||||||
|
Provider: "google",
|
||||||
|
ProviderUserID: u.Sub,
|
||||||
|
Email: strings.ToLower(strings.TrimSpace(u.Email)),
|
||||||
|
EmailVerified: u.EmailVerified,
|
||||||
|
Name: strings.TrimSpace(u.Name),
|
||||||
|
AvatarURL: u.Picture,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Facebook ---
|
||||||
|
|
||||||
|
type facebook struct{ id, secret string }
|
||||||
|
|
||||||
|
func (f *facebook) Name() string { return "facebook" }
|
||||||
|
|
||||||
|
func (f *facebook) Config(redirectURL string) *oauth2.Config {
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: f.id,
|
||||||
|
ClientSecret: f.secret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"email", "public_profile"},
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: "https://www.facebook.com/v18.0/dialog/oauth",
|
||||||
|
TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// facebookMe captures the fields requested via ?fields=id,email,name,picture.
|
||||||
|
// picture is an envelope: { data: { url, ... } }.
|
||||||
|
type facebookMe struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture struct {
|
||||||
|
Data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"picture"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *facebook) FetchProfile(ctx context.Context, tok *oauth2.Token) (*Profile, error) {
|
||||||
|
const url = "https://graph.facebook.com/v18.0/me?fields=id,email,name,picture.type(large)"
|
||||||
|
body, err := httpGet(ctx, tok, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("facebook /me: %w", err)
|
||||||
|
}
|
||||||
|
var u facebookMe
|
||||||
|
if err := json.Unmarshal(body, &u); err != nil {
|
||||||
|
return nil, fmt.Errorf("facebook /me decode: %w", err)
|
||||||
|
}
|
||||||
|
if u.ID == "" {
|
||||||
|
return nil, fmt.Errorf("facebook /me: empty id")
|
||||||
|
}
|
||||||
|
return &Profile{
|
||||||
|
Provider: "facebook",
|
||||||
|
ProviderUserID: u.ID,
|
||||||
|
Email: strings.ToLower(strings.TrimSpace(u.Email)),
|
||||||
|
EmailVerified: u.Email != "", // Facebook returns an email only if user granted the scope; we trust it
|
||||||
|
Name: strings.TrimSpace(u.Name),
|
||||||
|
AvatarURL: u.Picture.Data.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpGet performs a token-authenticated GET and returns the response body.
|
||||||
|
// The provider tokens include their auth header already (Bearer for Google,
|
||||||
|
// query-param for Facebook), so we let oauth2.Client handle it.
|
||||||
|
func httpGet(ctx context.Context, tok *oauth2.Token, url string) ([]byte, error) {
|
||||||
|
cli := oauth2.NewClient(ctx, oauth2.StaticTokenSource(tok))
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := cli.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return io.ReadAll(resp.Body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statePayload is the JSON shape we round-trip through the OAuth state
|
||||||
|
// parameter. The cookie holds the same encoded blob; on callback we compare
|
||||||
|
// the two byte-for-byte before trusting either.
|
||||||
|
//
|
||||||
|
// `Next` is the post-login redirect path on the frontend. We sign it (along
|
||||||
|
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
|
||||||
|
// user to an arbitrary URL after sign-in.
|
||||||
|
type statePayload struct {
|
||||||
|
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
|
||||||
|
Next string `json:"u"` // safe path, validated by handler
|
||||||
|
IssuedAt int64 `json:"t"` // unix seconds
|
||||||
|
Provider string `json:"p"` // "google" | "facebook"
|
||||||
|
}
|
||||||
|
|
||||||
|
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
|
||||||
|
// generous — typical OAuth round-trips finish in seconds. Longer than this
|
||||||
|
// and we discard the state to keep the replay window small.
|
||||||
|
const stateMaxAge = 10 * time.Minute
|
||||||
|
|
||||||
|
// EncodeState signs and packs a fresh state value bound to the given provider
|
||||||
|
// and post-login redirect path. The returned string is what goes into both
|
||||||
|
// the `state` query param and the cookie.
|
||||||
|
func EncodeState(secret []byte, provider, next string) (string, error) {
|
||||||
|
nonceB := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nonceB); err != nil {
|
||||||
|
return "", fmt.Errorf("state nonce: %w", err)
|
||||||
|
}
|
||||||
|
p := statePayload{
|
||||||
|
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
|
||||||
|
Next: next,
|
||||||
|
IssuedAt: time.Now().Unix(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
bodyEnc := base64.RawURLEncoding.EncodeToString(body)
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(bodyEnc))
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return bodyEnc + "." + sig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeState verifies the signature, checks the freshness window, and
|
||||||
|
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
|
||||||
|
// the handler maps it to a single error path.
|
||||||
|
func DecodeState(secret []byte, raw string) (provider, next string, err error) {
|
||||||
|
parts := strings.SplitN(raw, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
bodyEnc, sigEnc := parts[0], parts[1]
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(bodyEnc))
|
||||||
|
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
|
||||||
|
return "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
var p statePayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
return "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
|
||||||
|
return "", "", ErrInvalidState
|
||||||
|
}
|
||||||
|
return p.Provider, p.Next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidState is returned whenever signature or freshness checks fail.
|
||||||
|
// We deliberately return one error so handlers don't leak whether a request
|
||||||
|
// was tampered or just stale.
|
||||||
|
var ErrInvalidState = errors.New("invalid or expired oauth state")
|
||||||
Reference in New Issue
Block a user