You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

node-oidc-provider v7.14.3对接Alexa OAuth2授权码流请求过期错误排查

问题描述

我当前使用oidc-provider 7.14.3版本,为仅支持OAuth2的Alexa Skill实现用户认证。项目无法集成Keycloak这类工具,且只能使用oidc-provider 7.x版本。Implicit流程可以正常运行并获取id_token,但为适配Alexa,需要通过带PKCE的Authorization Code流程获取access_token。流程启动后,凭证验证、授权、回调环节都能正常执行,但客户端向/token端点请求access_token时,返回'invalid_request'及'authorization request has expired'错误,该问题对应oidc-provider v7.14.3版本中lib/actions/authorization/resume.js第16行的代码逻辑。

实现代码

AccountService.js

module.exports = class AccountService {
  ...
  static async findAccount(ctx, email) {
    ...
    return {
      accountId: appUser.id,
      async claims() {
        return {
          sub: appUser.id,
          email: appUser.email,
          active: appUser.active,
          tenantId: appUser.tenantId,
        };
      },
    };
  }

  async authenticateAppUser(email, password) {
    ...
    return appUser.id;
  }
};

boot.js

const parse = bodyParser.urlencoded({ extended: false });

function setNoCache(req, res, next) {
  res.set('Pragma', 'no-cache');
  res.set('Cache-Control', 'no-cache, no-store');
  next();
}

module.exports = async (app, container) => {
  const oidcService = container.resolve('oidcService');
  const oidcServer = await oidcService.prepareOidcServer();
  ...
  app.get('/auth/oidc/interaction/:uid', setNoCache, async (req, res, next) => oidcService.handleBeforeLoginEntrypoint(req, res, next, oidcServer));
  app.post('/auth/oidc/interaction/:uid/login', setNoCache, parse, async (req, res, next) => oidcService.handleLoginEntrypoint(req, res, next, oidcServer));
  app.post('/auth/oidc/interaction/:uid/confirm', setNoCache, parse, async (req, res, next) => oidcService.handleConfirmGrantEntrypoint(req, res, next, oidcServer));
  app.get('/auth/oidc/interaction/:uid/abort', setNoCache, async (req, res, next) => oidcService.handleLoginCancelEntrypoint(req, res, next, oidcServer));
  app.use('/auth/oidc', oidcServer.callback());
}

OidcService.js

const { Provider, interactionPolicy: { base } } = require('oidc-provider');

module.exports = class OidcService {
  constructor({
    accountService,
    logger,
    MysqlAdapter,
    OidcClient,
    OidcJwk,
  }) {
    this.accountService = accountService;
    this.logger = logger;
    this.MysqlAdapter = MysqlAdapter;
    this.OidcClient = OidcClient;
    this.OidcJwk = OidcJwk;
  }

  async prepareOidcServer() {
    const clients = {
      [{
        client_id: 'f4b840c6-17f3-4526-a331-a1ca4815289a',
        client_secret: '925c8dc2-20a0-4775-8650-4b18bebd68fa',
        redirect_uris: ['https://oauth.pstmn.io/v1/callback', 'https://layla.amazon.com/api/skill/link/<id>', 'https://pitangui.amazon.com/api/skill/link/<id>', 'https://alexa.amazon.co.jp/api/skill/link/<id>'],
        response_types: ['code'],
        grant_types: ['authorization_code'],
        token_endpoint_auth_method: 'client_secret_post',
      }];
    const jwks = await this.getJwksFromDb();
    const configuration = this.prepareOidcServerConfiguration(clients, jwks);
    const oidcServer = new Provider('https://example.com/auth/oidc/', configuration);
    return this.configureOidcServerErrorHandling(oidcServer);
  }

  prepareOidcServerConfiguration(clients, jwks) {
    return {
      adapter: this.MysqlAdapter,
      claims: {
        openid: ['sub'],
        email: ['email', 'active'],
        tenant: ['tenantId'],
      },
      clients,
      cookies: {
        keys: process.env.AUTH_OIDC_SECURE_KEY.split(','),
      },
      features: {
        devInteractions: { enabled: false },
        deviceFlow: { enabled: true },
        encryption: { enabled: true },
        introspection: { enabled: true },
        jwtResponseModes: { enabled: true },
        jwtUserinfo: { enabled: true },
        pushedAuthorizationRequests: { enabled: true },
        revocation: { enabled: true },
      },
      findAccount: this.accountService.findAccount,
      interactions: {
        policy: base(),
        url(ctx, interaction) {
          return `/auth/oidc/interaction/${interaction.uid}`;
        },
      },
      jwks: {
        keys: jwks,
      },
      proxy: true,
      renderError: this.renderError,
      ttl: {
        AccessToken: 300,
        AuthorizationCode: 1209600,
        ClientCredentials: 300,
        DeviceCode: 1209600,
        IdToken: 1209600,
        RefreshToken: 1209600,
        Session: 1209600,
      },
    };
  }

  async handleBeforeLoginEntrypoint(req, res, next, oidcServer) {
    try {
      const { uid, prompt, params } = await oidcServer.interactionDetails(req, res);
      const oidcClient = await oidcServer.Client.find(params.client_id);

      if (prompt.name === 'login') {
        return this.renderLoginPage(res, oidcClient, uid, prompt, params);
      }

      return this.renderAuthorizeInteraction(res, oidcClient, uid, prompt, params);
    } catch (err) {
      this.logger.err(`An error happened on OIDC entrypoint 'before login'. Error: ${err}`);
      return next(err);
    }
  }

  async handleLoginEntrypoint(req, res, next, oidcServer) {
    try {
      const { uid, prompt, params } = await oidcServer.interactionDetails(req, res);

      if (prompt.name !== 'login') {
        return;
      }

      const oidcClient = await oidcServer.Client.find(params.client_id);
      const accountId = await this.accountService
        .authenticateAppUser(req.body.email, req.body.password);

      if (!accountId) {
        this.renderLoginErrorPage(res, oidcClient, uid, prompt, params, req);
        return;
      }

      const result = {
        login: { accountId },
      };

      await oidcServer.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
    } catch (err) {
      this.logger.err(`An error happened on OIDC entrypoint 'login'. Error: ${err}`);
      next(err);
    }
  }

  async handleConfirmGrantEntrypoint(req, res, next, oidcServer) {
    try {
      const consent = {};
      const {
        grantId,
        prompt: { name, details },
        params,
        session: { accountId },
      } = await oidcServer.interactionDetails(req, res);
      if (name !== 'consent') {
        return;
      }

      const newGrantId = await this.prepareGrant(grantId, oidcServer, accountId, params, details);
      if (!grantId) {
        consent.grantId = newGrantId;
      }

      await oidcServer.interactionFinished(req, res, { consent },
        { mergeWithLastSubmission: true });
    } catch (err) {
      next(err);
    }
  }

  async prepareGrant(grantId, oidcServer, accountId, params, details) {
    let grant;

    if (grantId) {
      // we'll be modifying existing grant in existing session
      grant = await oidcServer.Grant.find(grantId);
    } else {
      // we're establishing a new grant
      grant = new oidcServer.Grant({
        accountId,
        clientId: params.client_id,
      });
    }

    if (details.missingOIDCScope) {
      grant.addOIDCScope(details.missingOIDCScope.join(' '));
      // use grant.rejectOIDCScope to reject a subset or the whole thing
    }
    if (details.missingOIDCClaims) {
      grant.addOIDCClaims(details.missingOIDCClaims);
      // use grant.rejectOIDCClaims to reject a subset or the whole thing
    }
    if (details.missingResourceScopes) {
      // eslint-disable-next-line no-restricted-syntax
      for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
        grant.addResourceScope(indicator, scopes.join(' '));
        // use grant.rejectResourceScope to reject a subset or the whole thing
      }
    }

    return grant.save();
  }

  async handleLoginCancelEntrypoint(req, res, next, oidcServer) {
    try {
      const result = {
        error: 'access_denied',
        error_description: 'End-User aborted interaction',
      };
      await oidcServer.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
    } catch (err) {
      next(err);
    }
  }

  configureOidcServerErrorHandling(oidcServer) {
    oidcServer.on('grant.error', this.handleGlobalOidcErrors);
    oidcServer.on('introspection.error', this.handleGlobalOidcErrors);
    oidcServer.on('revocation.error', this.handleGlobalOidcErrors);
    oidcServer.on('server_error', this.handleGlobalOidcErrors);
    oidcServer.on('access_token.issued', this.handleGlobalOidcErrors);
    return oidcServer;
  }

  // eslint-disable-next-line no-unused-vars
  handleGlobalOidcErrors({ headers: { authorization }, oidc: { body, client } }, err) {
    this.logger.info(`OIDC authentication failed with error message: ${err}`);
  }

  renderLoginPage(res, oidcClient, uid, prompt, params) {
    return res.render('login', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params,
      title: 'Sign-in',
      flash: undefined,
    });
  }

  renderLoginErrorPage(res, oidcClient, uid, prompt, params, req) {
    res.render('login', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params: {
        ...params,
        login_hint: req.body.email,
      },
      title: 'Sign-in',
      flash: 'Invalid email or password.',
    });
  }

  renderAuthorizeInteraction(res, oidcClient, uid, prompt, params) {
    return res.render('interaction', {
      client: oidcClient,
      uid,
      details: prompt.details,
      params,
      title: 'Authorize',
    });
  }

  // eslint-disable-next-line no-unused-vars
  renderError(ctx, out, error) {
    ctx.type = 'html';
    ctx.body = `<!DOCTYPE html>
    <head>
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <meta charset="utf-8">
      <title>oops! something went wrong</title>
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
      <style>
        @import url(https://fonts.googleapis.com/css?family=Roboto:400,100);h1{font-weight:100;text-align:center;font-size:2.3em}body{font-family:Roboto,sans-serif;margin-top:25px;margin-bottom:25px}.container{padding:0 40px 10px;width:274px;background-color:#F7F7F7;margin:0 auto 10px;border-radius:2px;box-shadow:0 2px 2px rgba(0,0,0,.3);overflow:hidden}pre{white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word;margin:0 0 0 1em;text-indent:-1em}
      </style>
    </head>
    <body>
      <div class="container">
        <h1>oops! something went wrong</h1>
        ${Object.entries(out).map(([key, value]) => `<pre><strong>${key}</strong>: ${value}</pre>`).join('')}
      </div>
    </body>
    </html>`;
  }

  async getJwksFromDb() {
    const parsedJwks = [];
    const jwksFromDb = await this.OidcJwk.query().select('key');

    if (!jwksFromDb?.length) {
      return parsedJwks;
    }

    jwksFromDb.forEach((jwk) => {
      parsedJwks.push(this.parseJson(jwk.key));
    });

    return parsedJwks;
  }

  parseJson(value) {
    if (!value) {
      return '';
    }

    try {
      return JSON.parse(value);
    } catch (e) {
      this.logger.error(`Couldn't parse String as JSON with value: ${value}`);
      return '';
    }
  }
};

内容的提问来源于stack exchange,提问作者Dubstef

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.12 17:57:03