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

Help with a session based token REST API #48

Closed
jyounus opened this issue Apr 18, 2016 · 8 comments
Closed

Help with a session based token REST API #48

jyounus opened this issue Apr 18, 2016 · 8 comments

Comments

@jyounus
Copy link

jyounus commented Apr 18, 2016

Hey there,

The API web service that I've got uses a session based token which expires every 24h. This means I have to make a new call to (let's say) the /authenticate_session endpoint and obtain a new session token. Now I have to take the response from this and pass it for every subsequent API call for other endpoints.

That's all nice and simple to implement using your basicAuthToken example.

My problem is, how can I check the error response and retry the call automatically? For example, let's say I make a GET request for the /user endpoint with an expired JSON web token. Here's the error response I get:

entity: Optional(Siesta.Entity(content: [success: 0, error: {
    code = E1100;
    hint = Token;
    message = "Session token has expired.";
}]

What I'd like to do is whenever I get an error with the above code (E1100), I'd like it to call the /authenticate_session endpoint, use the new token that gets returned and automatically make another GET /user request. I don't want the initial GET /user request to fail, instead I want it to try and handle/recover from it. If it then fails, I want to update the UI etc.

Is this possible with Siesta right now or do I need to manually handle this (manually as in inside my resourceChanged delegate function, I manually check what the error is and handle it there for every endpoint the API supports)? Can I instead somehow handle this using the responseTransformer for the Service object?

Any help in the right direction would be appreciated.

Thanks

@pcantrell
Copy link
Member

Making this circuit work as you describe, where it retries automatically and you never see the auth failure, isn't really workable with the Siesta API as it stands.

You can do something close to what you want, if:

  1. you’re willing to see the auth error first and then see the refreshed data, and
  2. you only want to retry load requests (not POST etc).

Here’s a sketch:

configure {
  $0.config.beforeStartingRequest { resource, request in
    request.onFailure { error in
      if isAuthError(error) {
        doAuthorization() {
          resource.loadIfNeeded()
        }
      }
    }
  }
}

That would clearly need cleaning up and building out. Hopefully it helps.

@pcantrell
Copy link
Member

(And I should clarify: isAuthError() and doAuthorization() are hypotherical methods that you would write, and doAuthorization() calls the given closure on success.)

@vdka
Copy link
Contributor

vdka commented May 5, 2016

It would be nice to be able to do something like this:

public static func configureMe() {
  service.configure("/me") {
    $0.config.beforeStartingRequest { (resource, request) in
      guard API.isAuthed else {
        switch API.refreshToken {
        case let refreshToken?:
          request.pause() // Stop the request from going out until we are possibly authed.
          API.login(refreshToken: refreshToken).onCompletion { response in
            guard case .Success = response else {
              request.cancel(API.Error.requiresAuth)
              return
            }
            request.resume() // We are now authed and the request may proceed.
          }
        case nil:
          request.cancel(API.Error.requiresAuth)
        }
      }
    }
  }
}

What are your thoughts on a system like this?

@pcantrell
Copy link
Member

pcantrell commented Jun 7, 2016

Auto-reuath and auto-refresh would certainly have widespread appeal. @vdka, I could imagine something along those lines. Instead of request.pause(), I’d first investigate some framework-provided mechanism for letting beforeStartingRequest replace the request wholesale.

@jordanpwood
Copy link

I'm doing some initial evaluation of Siesta to handle an API with this exact issue. @vdka, can I ask, what approach did you end up with?

@vdka
Copy link
Contributor

vdka commented Jun 11, 2016

I haven't tackled the issue just yet @jordanpwood but I will in the next couple days, then report back.

@vdka
Copy link
Contributor

vdka commented Jun 16, 2016

The following should work, it doesn't do pre-auth for individual requests but it definitely could be made to. You will need some method of finding the expiration time of your tokens for this method (we are using a JWT which encodes expiration time)

final class API {

  static var service = Service()

  // ...

  static func login(username username: String, password: String) -> Request {

    let request = service.resource(.Auth)
      .request(.POST, json: ["grant_type": "password", "username": username, "password": password, "application_id": clientId])
      .onSuccess { entity in
        let json = entity.json

        guard let token = json["token"].string else {
          fatalError() // handle this better
        }

        guard let refreshToken = json["refresh_token"].string else {
          fatalError() // handle this better
        }

        API.token = token
        API.refreshToken = refreshToken
    }

    return request
  }

  static func login(refreshToken refreshToken: String) -> Request {

    let request = service.resource(.Auth)
      .request(.POST, json: ["grant_type": "refresh_token", "refresh_token": refreshToken, "application_id": clientId])
      .onSuccess { entity in
        let json = entity.json
        guard let token = json["token"].string else {
          fatalError()
        }

        guard let refreshToken = json["refresh_token"].string else {
          fatalError()
        }

        API.token = token
        API.refreshToken = refreshToken
    }

    return request
  }

  public static var token: String? {
    didSet {
      service.invalidateConfiguration()
      service.wipeResources()

      guard let token = token else { return }

      let jwt = try? JWTDecode.decode(token)

      tokenExpiry = jwt?.expiresAt
    }
  }

  public private(set) static var tokenExpiry: NSDate? {
    didSet {
      guard let tokenExpiry = tokenExpiry else { return }

      let timeToExpire = abs(tokenExpiry.timeIntervalSinceDate(NSDate()))

      // Somewhat before the expiration happens
      let timeToRefresh = NSDate(timeInterval: timeToExpire * 0.9, sinceDate: NSDate())

      log.info("Token refresh scheduled for \(timeToRefresh.descriptionWithLocale(NSLocale.currentLocale()))")

      NSTimer.after(timeToRefresh.timeIntervalSinceNow) {

        log.info("attempting auto token refresh")

        guard let refreshToken = API.refreshToken else { 
          log.warning("No refresh token set. Cannot auto-refresh") 
          return
        }

        API.login(refreshToken: refreshToken)
          .onSuccess { _ in log.info("Token refresh successful!") }
          .onFailure { log.error("Token refresh failed with \($0)") }
      }
    }
  }
}

@pcantrell
Copy link
Member

I think #98 resolved this. If it didn’t, feel free to reopen.

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

No branches or pull requests

4 participants