Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support OIDC flows for apps #3216

Merged
merged 16 commits into from
Apr 26, 2023
Merged

feat: support OIDC flows for apps #3216

merged 16 commits into from
Apr 26, 2023

Conversation

hperl
Copy link
Contributor

@hperl hperl commented Apr 6, 2023

Flow for Kratos AppAuth

  1. Call createNativeLoginFlow to create a flow of type api, and request a session token exchange code
  2. Kratos sends flow ID, the session token exchange code, and the SSO URL to the app
  3. App opens the SSO URL in the native browser
  4. Browser opens the SSO URL, the authorization endpoint takes the user through authentication and consent
  5. The authorization endpoint redirects to the callback url (for example https://nostalgic-cori-rxgved3nrs.projects.oryapis.com/self-service/methods/oidc/callback/google), containing the authorization code
  6. The browser opens that URL. Kratos exchanges the authorization code for an identity token, and invokes the redirect (through the post-login redirect) to an URL registered to be handled by the app (Android App links or iOS Universal links)
    7a. Upon receiving the URL, the app can retrieve the session token at /self-service/exchange-code-for-session-token&code=<session token exchange code>
    7b. If the app can't receive a deep link (for example, if we are simulating the app on the web), then the app can also poll /self-service/exchange-code-for-session-token&code=<session token exchange code> and wait for a 200 status code with the session.

Related issue(s)

Docs PR: ory/docs#1379

Checklist

  • I have read the contributing guidelines.
  • I have referenced an issue containing the design document if my change
    introduces a new feature.
  • I am following the
    contributing code guidelines.
  • I have read the security policy.
  • I confirm that this pull request does not address a security
    vulnerability. If this pull request addresses a security vulnerability, I
    confirm that I got the approval (please contact
    security@ory.sh) from the maintainers to push
    the changes.
  • I have added tests that prove my fix is effective or that my feature
    works.
  • I have added or changed the documentation.

Security Considerations

During the authentication process we need to ensure that the following entities are all under the same control:

  • A1: The app that started the flow
  • B1: The browser that performs the authentication with the OIDC provider
  • B2: The browser that performs the OIDC callback with Kratos
  • A2: The app that fetches the session token

For example, it should not be possible for an attacker to start a flow, trick a victim into performing the authentication with the OIDC provider, and then be able to fetch the session token in the name of the victim.

We ensure that A1, A2, B1, B2 are under the same control as follows:

  • The initial_session_token_exchange_code binds A1 and A2 by generating in when the flow starts and requiring it in exchange for the session token
  • The OIDC state parameter binds B1 and B2 by deriving the state from the flow ID and the hashed code, which guarantees that no attacker can forge the state for B2
  • The return_to_code binds B2 and A2 by redirecting the browser to the specified and whitelisted return_to URL and including the code in a query parameter.

It then follows that A1, A2, B1, B2 are all under the same control

@hperl hperl self-assigned this Apr 6, 2023
@hperl hperl marked this pull request as draft April 6, 2023 07:24
@hperl hperl changed the title feat: OIDC flows for apps feat: support OIDC flows for apps Apr 6, 2023
@codecov
Copy link

codecov bot commented Apr 11, 2023

Codecov Report

Merging #3216 (891e8aa) into master (7f232bf) will increase coverage by 0.03%.
The diff coverage is 75.85%.

❗ Current head 891e8aa differs from pull request most recent head 509ec60. Consider uploading reports for the commit 509ec60 to get more accurate results

@@            Coverage Diff             @@
##           master    #3216      +/-   ##
==========================================
+ Coverage   77.93%   77.96%   +0.03%     
==========================================
  Files         324      324              
  Lines       20732    20780      +48     
==========================================
+ Hits        16158    16202      +44     
+ Misses       3365     3363       -2     
- Partials     1209     1215       +6     
Impacted Files Coverage Δ
driver/registry.go 44.82% <ø> (ø)
selfservice/flow/flow.go 100.00% <ø> (ø)
selfservice/flow/login/error.go 69.84% <0.00%> (ø)
selfservice/flow/registration/error.go 68.11% <0.00%> (ø)
selfservice/strategy/oidc/error.go 75.00% <ø> (ø)
session/manager.go 100.00% <ø> (ø)
selfservice/flow/login/hook.go 84.67% <25.00%> (-0.93%) ⬇️
selfservice/flow/registration/hook.go 71.73% <25.00%> (-0.93%) ⬇️
selfservice/hook/session_issuer.go 79.41% <25.00%> (+0.46%) ⬆️
selfservice/strategy/oidc/strategy.go 60.35% <40.00%> (-0.88%) ⬇️
... and 14 more

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

@hperl hperl force-pushed the hperl/app-auth branch 4 times, most recently from 26ebe49 to 1e5f9ff Compare April 18, 2023 13:01
Copy link
Member

@aeneasr aeneasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice & clean, I have a few remarks. One question is whether we should add some tests to the oidc go test suite. It's a bit of duplication because we test the same thing in e2e, but those tests execute faster and are easier to debug / step through if something breaks

selfservice/flow/login/handler.go Outdated Show resolved Hide resolved
selfservice/flow/login/handler.go Outdated Show resolved Hide resolved
selfservice/hook/session_issuer.go Outdated Show resolved Hide resolved
selfservice/strategy/oidc/strategy.go Outdated Show resolved Hide resolved
test/e2e/playwright/tests/app_login.spec.ts Outdated Show resolved Hide resolved
test/e2e/playwright/tests/app_login.spec.ts Show resolved Hide resolved
test/e2e/playwright/tests/app_login.spec.ts Show resolved Hide resolved
selfservice/strategy/oidc/strategy_login.go Show resolved Hide resolved
Copy link
Member

@aeneasr aeneasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice, a couple of questions and remarks on the code-side. I will look into the flow description next!

Comment on lines +54 to +55
init_code = ? AND init_code <> '' AND
return_to_code = ? AND return_to_code <> '' AND
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the non-empty assertions when the parameters are provided?

Is this not missing an index? Have you checked whether querying this scans excessive rows?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the non-empty assertions when the parameters are provided?

This is only a precaution that we never fetch from empty credentials.

Is this not missing an index? Have you checked whether querying this scans excessive rows?

The index should be this: CREATE INDEX session_token_exchanges_nid_code_idx ON session_token_exchanges (init_code, nid);. I didn't verify this with EXPLAIN though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But that's missing return_to_code, no?

Copy link
Contributor Author

@hperl hperl Apr 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now verified through EXPLAIN that everything works as expected:

Fetch by flow ID

explain select * from session_token_exchanges
        where nid = '212e6a8b-8a8f-4e30-b1e2-99b8478d773b'
          and flow_id = '7cf7bca9-f94e-41a8-8b8c-cb213b8b9cd9'
          and init_code <> ''
          and return_to_code <> ''
          and session_id is not null;
Index Scan using session_token_exchanges_nid_flow_id_idx on session_token_exchanges  (cost=0.14..8.17 rows=1 width=372)
  Index Cond: ((flow_id = '7cf7bca9-f94e-41a8-8b8c-cb213b8b9cd9'::uuid) AND (nid = '212e6a8b-8a8f-4e30-b1e2-99b8478d773b'::uuid))
  Filter: ((session_id IS NOT NULL) AND ((init_code)::text <> ''::text) AND ((return_to_code)::text <> ''::text))

Fetch by code

explain select * from session_token_exchanges
        where nid = '212e6a8b-8a8f-4e30-b1e2-99b8478d773b'
          and init_code = '5JxAMjfYsOCQNwsEyPoXfbC3u0Dbqb6TyGoB7z9now0BPNaJt4itcqecYr9YSR4t'
          and init_code <> ''
          and return_to_code = '3bkURN1fYEN2PtfvdE3juvUHC6XAzmcQufvHfbQ8gWSRa85j8kpvnyBh2gvmI9q7'
          and return_to_code <> ''
          and session_id is not null;
Index Scan using session_token_exchanges_nid_code_idx on session_token_exchanges  (cost=0.14..8.17 rows=1 width=372)
  Index Cond: (((init_code)::text = '5JxAMjfYsOCQNwsEyPoXfbC3u0Dbqb6TyGoB7z9now0BPNaJt4itcqecYr9YSR4t'::text) AND (nid = '212e6a8b-8a8f-4e30-b1e2-99b8478d773b'::uuid))
  Filter: ((session_id IS NOT NULL) AND ((return_to_code)::text <> ''::text) AND ((return_to_code)::text = '3bkURN1fYEN2PtfvdE3juvUHC6XAzmcQufvHfbQ8gWSRa85j8kpvnyBh2gvmI9q7'::text))

In both cases, the index is used first to narrow down the rows. Since (nid, flow_id) and (nid, init_code) both narrow down the row to a single result, the remaining filter is very fast. Adding more fields to the indices would not improve performance.

if ft == flow.TypeAPI && r.URL.Query().Get("return_session_token_exchange_code") == "true" {
e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID)
if err != nil {
return nil, nil, errors.WithStack(herodot.ErrInternalServerError.WithWrap(err))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just return err - wrapping it here will probably lose some context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the user can't do anything about this we should return a 500 code. Is this always the case when we don't wrap? If so, I can remove the wrapping.

selfservice/flow/registration/handler.go Show resolved Hide resolved
Comment on lines +923 to +929
InitCode string `json:"init_code"`

// The part of the code returned by the return_to URL.
//
// required: true
// in: query
ReturnToCode string `json:"return_to_code"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, this endpoint may support other token exchanges as well. Should we somehow prefix / clarify what code you're exchanging here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what other usages you think of here. If there are other types of tokens, wouldn't this be a different end point? It would probably be easier to reason about the security then?

session/handler.go Show resolved Hide resolved
// 404: errorGeneric
// 410: errorGeneric
// default: errorGeneric
func (h *Handler) exchangeCode(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go tests for this? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session/manager_http.go Outdated Show resolved Hide resolved
selfservice/strategy/oidc/strategy.go Outdated Show resolved Hide resolved
@hperl hperl requested a review from aeneasr April 21, 2023 18:22
@aeneasr
Copy link
Member

aeneasr commented Apr 23, 2023

I wanted to execute the tests to check the flow end-to-end, but I was not able to do so, because of:

% npm run playwright

> @ory/kratos-e2e-suite@0.0.1 playwright
> playwright test

ReferenceError: Cypress is not defined

   at cypress/helpers/index.ts:43

  41 |   new DOMParser().parseFromString(html, "text/html")

How can I execute the tests?

Additionally I found the code in the react app a bit confusing. I kind of would have expected the code to be more obvious, but it's hiding behind several nested function calls that are relatively deep in the stack trace.

This made it difficult to do a final end-to-end review. It would be really helpful if there was a simple Makefile command (or something similar) to get the playwright tests started!

@hperl
Copy link
Contributor Author

hperl commented Apr 24, 2023

[...]

Additionally I found the code in the react app a bit confusing. I kind of would have expected the code to be more obvious, but it's hiding behind several nested function calls that are relatively deep in the stack trace.

The entry point to the OIDC handling in the native app is the 422 error returned by Kratos. @jonas-jonas told me that you discussed this with him as a means to signal to the native app that it needs to get a browser involved. If you have ideas to improve the code lets meet here: ory/kratos-selfservice-ui-react-native#69

This made it difficult to do a final end-to-end review. It would be really helpful if there was a simple Makefile command (or something similar) to get the playwright tests started!

I added a make target. You can now run the Playwright tests with

RN_UI_PATH="path/to/kratos-selfservice-ui-react-native" make test-e2e-playwright

(env var needed until ory/kratos-selfservice-ui-react-native#69 is merged)

@Maragues
Copy link

The entry point to the OIDC handling in the native app is the 422 error returned by Kratos.

Sorry to show out of the blue, but from client perspective a 422 forces us to handle things in a weird way. I'm using this PR to implement our OIDC login, but since there's no java client yet, we are doing raw calls with retrofit.

Given this definition

@Headers("Content-Type: application/json")
@POST("self-service/login")
fun getSocialSignInUrl(@Query("flow") flowId: String, @Body body: SocialSignInBody): Call<SocialSignInUrlResponse>

It's normally straightforward to get the response body through

response.body()

But because you are returning a 422 error code, Retrofit doesn't attempt to parse the body and encapsulates everything on errorBody, which forces me to parse it manually

return when (response.code()) {
                422 -> {
                    val errorBody = response.errorBody().string()

                    RetrofitClientFactory.gson.fromJson(errorBody, SocialSignInUrlResponse::class.java)
                }

                else -> throw ...
}

This won't affect your java client because you parse everything manually, but my two cents as an android developer.

I hope this gives you more information and maybe you reconsider using an error code on a successful response.

@aeneasr
Copy link
Member

aeneasr commented Apr 25, 2023

Some of the tests are failing for me:

Screenshot 2023-04-24 at 18 19 52

Fruthermore, as far as I understand we now have two codes:

  1. One code is returned by createNativeFlow
  2. Second code is returned as part of return URL

We need both codes to complete the flow - is that correct?

@hperl
Copy link
Contributor Author

hperl commented Apr 25, 2023

Fruthermore, as far as I understand we now have two codes:

One code is returned by createNativeFlow
Second code is returned as part of return URL
We need both codes to complete the flow - is that correct?

Yes that's correct.

Copy link
Member

@jonas-jonas jonas-jonas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code-wise this looks great to me. E2e just straight up fail for me locally. But probably an issue on my machine.

selfservice/flow/registration/handler.go Outdated Show resolved Hide resolved
spec/api.json Outdated
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the response code for the registration flow handler missing here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your comment lost it's link to the source code. What is the issue here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just wondering whether the (current) 422 response from the registration handler is documented in the open api doc.

test/e2e/playwright/kratos.config.json Outdated Show resolved Hide resolved
test/e2e/playwright/tests/app_login.spec.ts Show resolved Hide resolved
@aeneasr aeneasr merged commit cb10609 into master Apr 26, 2023
@aeneasr aeneasr deleted the hperl/app-auth branch April 26, 2023 13:09
@jeremy-serenne jeremy-serenne mentioned this pull request Jun 19, 2023
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants