使用supabase/auth实现OAuth登录

June 24, 2024

前面已经介绍过了如何使用supabase/auth来实现邮箱用户的认证以及supabase的源码分析,本次重点介绍如何使用next.js(服务端渲染) + supabase/auth来实现OAuth2.0的认证,从应用的web/backend端、supabse以及provider的角度来分析其流程及原理。

相关代码已经在Next-Blogger中实现,细节可以自行参考。

整体流程

角色

涉及到的角色包括:

  • web:本文指应用前端,浏览器,即用户
  • backend:本文这里指Nextjs实现的后端,代表应用服务端后端
  • supabase:suapbase服务端缩写,这里使用官方部署。自己部署时地址、cgi path可能略有差异
  • provider:github、google等

流程图

传统的OAuth2.0中Authorization Code Grant模式如下:

抛开资源访问的Resource Server,只有Resource Owner(类似web)、Application(类似backend)、Authorization Server(Provider)三个角色。流程上仅有获取 Authorization Code(redirect)和 Exchange Token两步。而引入Supabase作为认证代理之后,整体流程是会变得复杂较多,但是对用户体验基本无感知。

 

Supabase的主要思路就是对上面两步的封装

  • Authorization Code:在跳转到Resouce Server(Provider)之前需要先跳转Supabase的authorize地址。以获取Provider的参数信息。同时在Oauth中的callback地址也变为了Supabase提供的callback地址,Supabase Server处理完毕后才跳转应用想要的callback地址,同时携带的code已经是Supabase自己的auth code。
  • Exchange Token:使用Auth code交换Token,跟OAuth的流程一致。不过这里的Code和Token都已经是Supabase内部的code&token了。应用基本无需感知Provider存在。

引入Supabase后的整体流程如下

整体流程大致如下:

  1. 应用发起登录,指定redirect_to参数,跳转Supaase的authroize地址(auth/v1/authorize)。
  2. Supaase的authorize根据provider配置,将用户302跳转到真正的Authorization Server的authorize地址(比如github中的 github.com/login/oauth/authorize),同时携带oauth标准参数 redirect_uri、client_id。
  3. 用户完成登录&授权,provider server会将code、state作为参数跳转到Supabase的callback地址(auth/v1/callback)。
  4. Supaase的callback会将provider的code转换为相关token信息。确认本次认证的合法性。同时将内部的auth code作为参数回跳到应用指定的redirect_to地址。
  5. 应用direct_to页面会将supbase返回的auth code转换为Supabase的session、jwt等。完成本次认证。
  6. 应用可以在Suapbase的JWT信息解析用户在provider中的信息,包括名称、头像等。具体跟provider、scope等相关。

流程解析

发起登录:web → backend

Web发起某个provider类型的登录,以获取provider对应的OAuth authorize接口地址以及client_id等信息,这部分信息是配置在Supabase服务端侧的(包括client_id、auth callback等),所以第一跳的地址应该是Supbase服务端地址,而非直接的Provider authorize地址。

[javascript]
export async function actSignInWithOAuth({ provider }: { provider: Provider }) {
  const supabase = createClient();

  // 根据provider生成supabase/auth 地址以及参数。携带redirectTo(完成授权后的业务地址callback)
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider,
    options: {
      redirectTo: absoluteUrl("auth/callback"),
    },
  });
  if (error) {
    redirect("/auth/error");
  }

  // revalidate the path to ensure that the user is logged in?
  revalidatePath("/", "layout");

  // 302跳转supabase的auth 地址
  if (data.url) {
    redirect(data.url); // use the redirect API for your server framework
  }
}

应用服务端会调用SDK内的_getUrlForProvider接口,构建好Supabse对应的跳转地址,以302跳转的方式返回给web端开始执行后续的一系列跳转逻辑。

应用服务端的生成的Supabase服务端的地址格式大致如下:

[json]
{
  "provider": "github",
  "url": "https://xxxxxx.supabase.co/auth/v1/authorize?provider=github&redirect_to=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback&code_challenge=qnxFcWdM82JiOMPibcPa9_jiP4b1n6FVHx82QYLs_Ag&code_challenge_method=s256"
}

URL中的参数说明:

  • providerredirect_to: web端指定。期望的provider和授权完成的callback地址。类似OAuth2.0的exchange token流程,callback页会拿Supabase的auth code去换取JWT、session等。
  • code_chalangecode_challenge_method:生成的PKCE的参数。

关于PKCE模式的说明:

@supabase/ssr 中createClient时会将flowType模式定义为PKCE。所以会在backend端生成随机串 code_verifier ,并根据其生成 code_challenge 以及 code_challenge_method 。其中:

  • code_verifier 用于向Supabase获取token时使用。因为是在backend端生成,所以直接通过cookie下发给了web,避免了backend的数据存储以及映射。
  • code_challengecode_challenge_method 用于在向Supabase获取code(authorize接口)时使用。算法code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

在 OAuth 2.0 授权框架中,Proof Key for Code Exchange(简称 PKCE) 是一种增强安全性的机制,主要用于保护公共客户端(如移动应用、单页应用等)在授权过程中免受授权码拦截和重放攻击。PKCE 协议本身是对 OAuth 2.0 的扩展, 它和之前的授权码流程大体上是一致的, 区别在于, 在向授权服务器的 authorize endpoint 请求时,需要额外的 code_challenge 和 code_challenge_method 参数, 向 token endpoint 请求时, 需要额外的 code_verifier 参数, 最后授权服务器会对这三个参数进行对比验证, 通过后颁发令牌。这样就避免了client_secret的明文传递

在 OAuth 2.0 核心规范中, 要求授权服务器的 anthorize endpoint 和 token endpoint 必须使用 TLS(安全传输层协议)保护, 但是授权服务器携带授权码code返回到客户端的回调地址时(redirect), 有可能不受TLS 的保护, 恶意程序就可以在这个过程中拦截授权码code, 拿到 code 之后, 接下来就是通过 code 向授权服务器换取访问令牌 access_token , 对于机密的客户端来说, 请求 access_token 时需要携带客户端的密钥 client_secret , 而密钥保存在后端服务器上, 所以恶意程序通过拦截拿到授权码code 也没有用, 而对于公开的客户端(手机App, 桌面应用)来说, 本身没有能力保护 client_secret, 因为可以通过反编译等手段, 拿到客户端 client_secret, 也就可以通过授权码 code 换取 access_token, 到这一步,恶意应用就可以拿着 token 请求资源服务器了。

Supbase的Authorize接口

目的:Supabase的Authorize接口( /auth/v1/authorize)的主要作用是根据用户请求参数(provider、scopes、code_challenge、code_challenge_method)跳转到对应的第三方provider的Authorize接口地址,以获取OAuth流程中的code授权码。

构建authorize URL时大部分采用的是第三方lib OAuth2 for Go ,部分provider自定义实现或采用其他第三方lib。

[go]
// Provider returns a Provider interface for the given name.
func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) {
	config := a.config
	name = strings.ToLower(name)

	switch name {
	case "apple":
		return provider.NewAppleProvider(ctx, config.External.Apple)
	case "azure":
		return provider.NewAzureProvider(config.External.Azure, scopes)
	case "bitbucket":
		return provider.NewBitbucketProvider(config.External.Bitbucket)
	case "github":
		return provider.NewGithubProvider(config.External.Github, scopes)
	// ... Ignore other provider here
	default:
		return nil, fmt.Errorf("Provider %s could not be found", name)
	}
}
// Construct provider object option
func NewGithubProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
	if err := ext.ValidateOAuth(); err != nil {
		return nil, err
	}

	authHost := chooseHost(ext.URL, defaultGitHubAuthBase)
	apiHost := chooseHost(ext.URL, defaultGitHubAPIBase)
	if !strings.HasSuffix(apiHost, defaultGitHubAPIBase) {
		apiHost += "/api/v3"
	}

	oauthScopes := []string{
		"user:email",
	}

	if scopes != "" {
		oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
	}

	return &githubProvider{
		Config: &oauth2.Config{
			ClientID:     ext.ClientID[0],
			ClientSecret: ext.Secret,
			Endpoint: oauth2.Endpoint{
				AuthURL:  authHost + "/login/oauth/authorize",
				TokenURL: authHost + "/login/oauth/access_token",
			},
			RedirectURL: ext.RedirectURI,
			Scopes:      oauthScopes,
		},
		APIHost: apiHost,
	}, nil
}

// generate authorize URL for porvider
// from golang.org/x/oauth2
func (c *Config) AuthCodeURL(state string, opts ...AuthCodeOption) string {
	var buf bytes.Buffer
	buf.WriteString(c.Endpoint.AuthURL)
	v := url.Values{
		"response_type": {"code"},
		"client_id":     {c.ClientID},
	}
	if c.RedirectURL != "" {
		v.Set("redirect_uri", c.RedirectURL)
	}
	if len(c.Scopes) > 0 {
		v.Set("scope", strings.Join(c.Scopes, " "))
	}
	if state != "" {
		v.Set("state", state)
	}
	for _, opt := range opts {
		opt.setValue(v)
	}
	if strings.Contains(c.Endpoint.AuthURL, "?") {
		buf.WriteByte('&')
	} else {
		buf.WriteByte('?')
	}
	buf.WriteString(v.Encode())
	return buf.String()
}

其他逻辑包括:

  1. 如果是pkce flowType,会在 flow_state 表中新增一条记录,记录pkce相关参数,用于记录本次请求的状态,等待后续access-token、refresh-token等值有了之后会更新。表中会两个uuid字段:id以及auth_code,id用于关联Provider的授权记录,会被作为加密串state的一部分;auth_code用作授权完成后backend取token的场景,用于定位flow_state 表记录。
  2. 构造第三方provider的 state 参数。用于关联本次 authorize 和后面的 token 请求。state使用类似token的算法,加密的元数据包括:
  • flowStateID: flow_state 中插入记录的id,用于后续更新 access/refresh-token。
  • providerType:provider类型,用于校验
  • 时间戳:用于防止重放等
  • redirectURL:原始的redirect_to ,用于授权完毕后的页面跳转。 OAuth 2.0 协议中的标准参数,用于指定客户端应用在用户授权完成后接收授权码或访问令牌的回调地址。通常为supabase服务端的 auth/v1/callback。

页面获取到Supbase返回的provider对应的授权接口地址后,就会按照标准的OAuth流程,完成provider的登录、授权流程。然后携带code、state参数跳转到Supbase提供的callback地址。

问题:

  1. redirect_to 参数去哪里了?
  • 虽然这个参数不是OAuth 2.0协议中的标准参数,但是redirect_to还是会被透传给provider。可能是部分oauth实现支持了此参数。
  • 使用jwt加密到了OAuth协议中的 stat 参数,传递给provider。state在完成授权后会给回传,从而可以还原 redirect_to 参数。
[go]
claims := ExternalProviderClaims{
		AuthMicroserviceClaims: AuthMicroserviceClaims{
			RegisteredClaims: jwt.RegisteredClaims{
				ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
			},
			SiteURL:    config.SiteURL,
			InstanceID: uuid.Nil.String(),
		},
		Provider:    providerType,
		InviteToken: inviteToken,
		Referrer:    redirectURL,  // 业务指定的 redirect_to 参数,存储在 referrer 字段中
		FlowStateID: flowStateID,
	}

  // 生成stat参数
	tokenString, err := signJwt(&config.JWT, claims)
	if err != nil {
		return "", apierrors.NewInternalServerError("Error creating state").WithInternalError(err)
	}
	
	// ...
	authURL := p.AuthCodeURL(tokenString, authUrlParams...)

2、登录请求中的 redirect_to 参数为何没有生效?

在测试过程中发现,我指定的redirect_to参数没有生效,而是跳转到了supabase中 site_url 配置的地址,排查后发现问题出在了子域名的问题上。个人项目中指定的direct_to为主域名 goroutine.cn ,而 site_url 中配置的域名为 www.goroutine.cn ,而在上面的 Referrer 参数处理时会校验redirect_to的准确性,要求域名完全相等。或者在 uri_allow_list 存在配置。

[go]
// 解析 redirec_to 参数,并校验合法性。非法时返回 site_url 代替。
func GetReferrer(r *http.Request, config *conf.GlobalConfiguration) string {
	// try get redirect url from query or post data first
	reqref := getRedirectTo(r)
	if IsRedirectURLValid(config, reqref) {
		return reqref
	}

	// instead try referrer header value
	reqref = r.Referer()
	if IsRedirectURLValid(config, reqref) {
		return reqref
	}

	return config.SiteURL
}

遇到此问题可以通过jwt解析工具将state参数解析为原始的jwt对象,确认里面 referer 参数的准确性。

修复方法有两种:

  1. 将不带 www 的子域名接入层强制跳转为 www 的子域名。降低后续的维护成本。
  2. uri_allow_list 中增加 多个子域名。

Supbase的callback接口

目的:接收Provider用户授权完毕后的codestate参数,以换取access/refresh-token。

作为标准OAuth2中Authorization Code Grant模式的一环,Callback接口地址一般配置在provider提供的配置环境中,各Provider的配置方法参考官网文档。Supbase中的Callback地址可以在Provider对应的配置中取到,如图:

 

流程

  1. 解析state,查找对应的flow_state 表记录。( api.loadFlowState()
  2. 根据userdata中的email,创建用户身份 useridentitycreateAccountFromExternalIdentity
  3. 更新flow_state 中的token、userid等。非pkce flowType时直接颁发token,写入cookie。
  4. flow_state 表中的auth_code作为参数以302的方式给到web,跳转地址为web发起跳转时的redirect url(或者supabase中的默认配置)。

 

至此,Supabase与Provider之间的OAuth授权流程基本完毕了,Supabase服务端已经存储了access/refresh-token以及用户数据。现在要做的就是通知backend从Supabase来提取相关信息,并构建session写入web的cookie中,完成web端的登录。

完成登录:web → backend

目的:web发起登录时携带 redirect_url 为最终的登录结果地址,Supabase完成授权流程后,会将code透传给到此地址,应用端需要使用此code换取session、JWT等,实现页面的登录效果。

backend逻辑

backend会调用Supabase的 /token 接口(通过ssr SDK中的 supabase.auth.exchangeCodeForSession(code)实现),将code转换为session以及JWT。

Supabase逻辑

API.PKCE() 方法中,完成code的校验以及登录态的下发。主要流程包括:

  1. 参数校验: auth_code 为上一步中返回的code, code_verifier 为之前Backend存储在cookie中的PKCE参数,调用Supabase时会从cookie中取出并删除cookie项。
  2. 根据auth_code 查找对应的flow_state 记录。校验合法性(user_id非空、过期时间FlowStateExpiryDuration)
  3. 取出flow_state 中数据code_challengecode_challenge_method,校验PKCE参数:code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
  4. 记录审计日志。
  5. 颁发access/refresh-token:包括生成session、token,参考 supabase/auth源码分析
  6. 相对邮箱认证,也会将provider返回的access-token、refresh-token(如果有)一并返回。
[json]
{
  "access_token": "xxx.xxxx.xxx",
  "token_type": "bearer",
  "expires_in": 3600,
  "expires_at": 1719229897,
  "refresh_token": "z41prc_xxxxxxxxxx",
  "user": {
    "id": "d87bb2ff-8ac4",
    "aud": "authenticated",
    "role": "authenticated",
    "email": "xxx@gmail.com",
    "email_confirmed_at": "",
    "phone": "",
    "confirmation_sent_at": "",
    "confirmed_at": "",
    "last_sign_in_at": "",
    "app_metadata": {
      "provider": "email",
      "providers": []
    },
    "user_metadata": {
      "avatar_url": "",
      "email": "xxx@gmail.com",
      "email_verified": true,
      "full_name": "",
      "iss": "https://api.github.com",
      "name": "",
      "phone_verified": false,
      "preferred_username": "alex-guoba",
      "provider_id": "",
      "sub": "",
      "user_name": "alex-guoba"
    },
    "created_at": "",
    "updated_at": "",
    "is_anonymous": false
  },
  "provider_token": "yyyyyy"
}

最后,web端SDK会将上面的数据写入cookie。表示最终的OAuth登录流程已完成。

小结

  1. 调试过程中遇到了cookie设置未生效的问题,网上也有部分人遇到过,cookie的数据过长(超过4k)会导致写入登录态无效。但是非必现,看解决方案新版sdk做了优化,对cookie中的数据做了一些裁剪。
  2. Supabase作为oauth的代理,需要兼容各种Provider的流程,所以在supbase/auth的代码中对不同的Provider做了部分特殊流程处理。如果发现部分Provider的问题,可能跟这种自定义实现有关,可以通过排查代码、文档定位。
  3. 一直觉得Supabase的cookie太长了,在cookie最大限制的边缘。如果应用中发现登录态丢失的问题,可能是业务自定义cookie与登录态超长导致。在部署过程中遇到了Nginx header超长的问题 “upstream sent too big header while reading response header from upstream”。调整最大长度后才通过。
See all postsSee all posts